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.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-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회, 보존기간) ·
|
||||
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
|
||||
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
|
||||
`dispatch_alerts.php` 는 매 실행마다 `move_app_state['dispatch_alerts.last_run']` 을 남기고,
|
||||
대시보드(`/api/devices` → `dispatcher`)가 그 나이를 읽어 크론이 멈춘 것을 표시한다.
|
||||
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
|
||||
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
|
||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||||
|
||||
> **판정 (2026-09-21, 1.2.1): 적합 — 오프하드웨어 항목 전량 해소.**
|
||||
> **판정 (2026-09-25, 1.3.0): 적합 — 오프하드웨어 항목 전량 해소.**
|
||||
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
|
||||
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
|
||||
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
|
||||
> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책.
|
||||
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
|
||||
|
||||
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
|
||||
|
|
@ -532,6 +533,54 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
|
|||
|
||||
---
|
||||
|
||||
## 5차 — 이해관계자 페르소나 검증 (2026-09-25, 1.3.0)
|
||||
|
||||
앞선 네 번은 전부 "이 코드가 제대로 도는가"를 물었다. 이번에는 **"이 기능이 존재해야 할
|
||||
이유가 실제로 닿는가"** 를 물었다 — 작업자·노조 대표·안전관리자·공장장·설치기사 5인의
|
||||
관점에서 각각 3단논법을 세우고, 전제를 코드로 확인한 뒤 반문으로 깼다.
|
||||
|
||||
5인 중 2인의 논증이 완전히 무너졌고 2인이 부분적으로 무너졌다. 넷 다 같은 모양이었다:
|
||||
구현도 단위테스트도 초록인데, **그 기능이 지켜야 할 것이 지켜지지 않는 상태.**
|
||||
|
||||
- [x] **S5-1 [B]** 스냅샷 열람·대량 반출이 감사기록에 남지 않았다 (`SnapshotProxy::serve`,
|
||||
`ExportController::labels`). 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 읽기는 하나도
|
||||
남지 않았다 — 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기고, 나머지 프라이버시
|
||||
설계 전부가 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
|
||||
DONE — `snapshot_view`(세션당 사진 1회) · `label_export`(건수·기간). 거부된 열람은
|
||||
기록하지 않는다(감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되면 쓸 수 없다).
|
||||
기록 실패가 사진 열람을 막지 않되 세션 메모에 넣지 않아 다음 요청이 재시도한다.
|
||||
테스트 `ReadAuditTest` 8건.
|
||||
|
||||
- [x] **S5-2 [B]** 통보 크론이 멈춘 것을 아무도 감지하지 못했다. 통보·5분 확대·`device_fault`
|
||||
발행이 전부 `dispatch_alerts.php` 한 프로세스에 걸려 있는데, 그게 멈추면 화면은 조용해지고
|
||||
그 침묵이 "사고 없음"과 구별되지 않는다. 관리자 화면의 조치는 산문 안내 한 줄이었다.
|
||||
DONE — 크론이 `move_app_state` 에 실행 시각을 남기고 `/api/devices` 가 그 나이를 읽는다
|
||||
(5분 기준). "한 번도 안 돎"과 "멈춤"을 구별한다 — 조치가 다르기 때문이다. 마이그레이션 전
|
||||
배포에서 상태 패널 전체가 죽지 않도록 예외를 삼킨다. 테스트 `DispatcherLivenessTest`.
|
||||
|
||||
- [x] **S5-3 [H]** 스냅샷 삭제가 나이만 봤다(`purgeOlderThan`). 기본 7일인데 중대재해 조사는
|
||||
몇 주~몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다.
|
||||
DONE — `move_events.hold_until`. admin 만, 건별로, 최대 1년, 감사기록과 함께. 확인 처리로
|
||||
자동 연장하지 않는다(자동이면 사실상 전량 보존이 되어 7일 약속이 무너진다). 고아 스냅샷은
|
||||
LEFT JOIN 으로 여전히 삭제 대상 — INNER JOIN 이면 영원히 안 지워지는 PIPA 누출이 된다.
|
||||
테스트 `EvidenceHoldTest` 8건.
|
||||
|
||||
- [x] **S5-4 [H]** 증거 프레임이 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었다. 체류 상한
|
||||
초과(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로
|
||||
일하는 동안 주기적으로 본인 사진이 서버로 갔다 — 위험해서가 아니라 오래 있어서.
|
||||
DONE — `edge/app.py` `NO_EVIDENCE_TYPES`. 기준은 "시간 경과 자체가 조건인 이벤트에는
|
||||
사진을 붙이지 않는다". 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이라 유지.
|
||||
`run_once` 를 실제로 태워 같은 배치에서 한쪽은 증거를 들고 다른 쪽은 안 드는지 본다.
|
||||
|
||||
### 이번 검증에서 확인한 잔여 (코드로 닫을 수 없음)
|
||||
|
||||
- **노조 대표 열람 계정** — 감사기록이 생겼으므로 이제 의미를 갖는다. 계정 발급은 사업주 결정.
|
||||
- **보관기간 화면 표시** — 설정 파일 안이 아니라 눈에 보이는 곳에. 다음 UI 개정에서.
|
||||
- **SHA256SUMS 미서명** — `SIGN_KEY` 미설정이라 손상은 잡고 위변조는 못 잡는다. 폐쇄망 USB
|
||||
전달 경로의 위협 모델에서는 수용 가능하고 문서에 정직하게 적혀 있다. 결정으로 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
|
||||
|
||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
|---|---|---|---|
|
||||
| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` |
|
||||
| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` |
|
||||
| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제** | `Config::snapshotRetentionDays() = 7` |
|
||||
| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제**. 예외는 아래 '보존 보류' 한 가지 | `Config::snapshotRetentionDays() = 7` |
|
||||
| 스냅샷 **열람** | 서버 | **누가 언제 어느 사진을 열었는지 감사기록에 남음** | `SnapshotProxy::recordView()`, 감사 동작 `snapshot_view` |
|
||||
| 기록 **반출** | 서버 | 대량 내보내기도 건수와 함께 기록됨 | `ExportController`, 감사 동작 `label_export` |
|
||||
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` |
|
||||
|
||||
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
|
||||
|
|
@ -106,22 +108,24 @@
|
|||
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
|
||||
지우거나 고치면 검증에서 드러납니다
|
||||
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
|
||||
- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다.
|
||||
쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다
|
||||
- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로
|
||||
발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에
|
||||
들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`)
|
||||
|
||||
### 아직 없는 것 — 보완을 권고합니다
|
||||
### 남은 것 — 운영으로 해야 하는 두 가지
|
||||
|
||||
**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기
|
||||
동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의
|
||||
`audit->append` 호출부 기준).
|
||||
열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고
|
||||
지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에
|
||||
남습니다. 대량 반출도 건수와 함께 기록됩니다.
|
||||
|
||||
이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다.
|
||||
"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다.
|
||||
코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다.
|
||||
|
||||
권고 3가지:
|
||||
|
||||
1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지.
|
||||
2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
|
||||
1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
|
||||
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
|
||||
3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
|
||||
기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다.
|
||||
2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -150,6 +154,25 @@
|
|||
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
|
||||
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
|
||||
|
||||
#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가)
|
||||
|
||||
중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이
|
||||
사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다.
|
||||
|
||||
그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유:
|
||||
|
||||
| | 내용 |
|
||||
|---|---|
|
||||
| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 |
|
||||
| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 |
|
||||
| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 |
|
||||
| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 |
|
||||
| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 |
|
||||
|
||||
노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린
|
||||
건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동
|
||||
연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다.
|
||||
|
||||
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
|
||||
|
||||
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@
|
|||
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
|
||||
> 않습니다 — 사고 순간의 스냅샷 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.
|
||||
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
|
||||
|
||||
# 사진을 붙이지 않는 유형.
|
||||
#
|
||||
# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은
|
||||
# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서
|
||||
# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다:
|
||||
#
|
||||
# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다.
|
||||
# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고,
|
||||
# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로
|
||||
# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다.
|
||||
# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다.
|
||||
#
|
||||
# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가
|
||||
# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다.
|
||||
NO_EVIDENCE_TYPES = frozenset({
|
||||
EventType.ERGO_REPORT,
|
||||
EventType.OVERSTAY,
|
||||
EventType.DEVICE_FAULT,
|
||||
EventType.MONITORING_DEGRADED,
|
||||
})
|
||||
|
||||
|
||||
def _head_boxes(persons: list) -> list:
|
||||
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
|
||||
|
|
@ -85,10 +106,8 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
|||
for tr in lost:
|
||||
pool.forget(tr.track_id)
|
||||
if events:
|
||||
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
|
||||
# would turn a statistic into surveillance imagery. Capture only when a real incident
|
||||
# needs evidence, and never attach snapshots to the ergo report itself.
|
||||
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
|
||||
# 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
|
||||
evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
|
||||
snaps = []
|
||||
if evidential:
|
||||
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
|
||||
|
|
@ -98,7 +117,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
|||
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
|
||||
snaps = snapshotter.capture(frames, boxes)
|
||||
for e in events:
|
||||
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
|
||||
sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps)
|
||||
return events
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
|
|||
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
|
||||
| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 |
|
||||
| 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 |
|
||||
| 10 | `2026-09-25-evidence-hold-and-view-audit.sql` | 증거 보존 보류(`hold_until`) + 통보 크론 생존 기록(`move_app_state`) |
|
||||
|
||||
1. phpMyAdmin → 해당 DB → **SQL** 탭
|
||||
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
if (data && data.storage) renderStorage(data.storage);
|
||||
if (data) renderDispatcher(data.dispatcher);
|
||||
if (data && Array.isArray(data.devices)) {
|
||||
deviceList = data.devices;
|
||||
devicesLoaded = true;
|
||||
|
|
@ -1036,6 +1037,35 @@
|
|||
+ '보존기간 설정을 줄이거나 용량을 확보하세요.'));
|
||||
}
|
||||
|
||||
// --- notification dispatcher liveness -----------------------------------------------
|
||||
// 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이
|
||||
// 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에
|
||||
// 걸려 있어서, 멈춘 순간부터 이 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이 보인다.
|
||||
// 감시자의 생존은 감시자가 아닌 쪽이 읽어야 해서, 그 판정이 여기 있다.
|
||||
var dispatchWarn = document.getElementById('dispatch-warn');
|
||||
|
||||
function renderDispatcher(d) {
|
||||
if (!dispatchWarn) return;
|
||||
if (!d || !d.stale) { dispatchWarn.hidden = true; return; }
|
||||
dispatchWarn.hidden = false;
|
||||
dispatchWarn.textContent = '';
|
||||
var never = d.last_run_at === null || d.last_run_at === undefined;
|
||||
dispatchWarn.appendChild(spanWith('warn-lead', never ? '통보가 설정되지 않았습니다' : '통보가 멈췄습니다'));
|
||||
dispatchWarn.appendChild(document.createTextNode(never
|
||||
? ' — 통보 디스패처가 한 번도 실행된 적이 없습니다. 크론에 tools/dispatch_alerts.php 를 1분 주기로 등록하세요. '
|
||||
+ '등록 전까지 메일·웹훅·5분 미확인 확대·장치 이상 알림이 나가지 않습니다 (현장 경광등은 영향 없음).'
|
||||
: ' — 마지막 실행 ' + fmtAgo(d.seconds_since) + ' 전 (기준 '
|
||||
+ Math.round(d.stale_after_s / 60) + '분). 이 상태에서는 화면이 조용한 것이 "사고 없음"이라는 뜻이 아닙니다. '
|
||||
+ '메일·웹훅·미확인 확대·장치 이상 알림이 전부 멈춰 있습니다 (현장 경광등은 영향 없음).'));
|
||||
}
|
||||
|
||||
function fmtAgo(s) {
|
||||
if (typeof s !== 'number') return '?';
|
||||
if (s < 120) return s + '초';
|
||||
if (s < 7200) return Math.floor(s / 60) + '분';
|
||||
return Math.floor(s / 3600) + '시간';
|
||||
}
|
||||
|
||||
// --- bulk acknowledge ----------------------------------------------------------------
|
||||
// For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall
|
||||
// is exactly the row a human must look at, and a control that can clear one in a sweep is a
|
||||
|
|
|
|||
|
|
@ -158,11 +158,13 @@
|
|||
.catch(function () { /* the POST surfaces it as a 403 */ });
|
||||
}
|
||||
|
||||
function post(path) {
|
||||
return fetch(B + '/events/' + encodeURIComponent(id) + path, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||
}).then(function (res) {
|
||||
function post(path, body) {
|
||||
var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } };
|
||||
if (body !== undefined) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(B + '/events/' + encodeURIComponent(id) + path, opt).then(function (res) {
|
||||
if (res.status === 401) { window.location.href = B + '/login'; return null; }
|
||||
if (!res.ok) throw new Error('action_failed:' + res.status);
|
||||
return res;
|
||||
|
|
@ -242,6 +244,60 @@
|
|||
actionsBox.appendChild(fp);
|
||||
}
|
||||
|
||||
// --- 증거 보존 보류 -------------------------------------------------------------------
|
||||
// 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 자체는
|
||||
// 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진이 사라졌다.
|
||||
//
|
||||
// renderActions 와 분리한 이유: 저쪽은 확인·오탐이 끝나면 조기 반환한다. 보류가 필요한
|
||||
// 상황은 바로 그 "이미 확인된 진짜 사고"라서, 같은 함수에 두면 정작 필요할 때 안 보인다.
|
||||
var holdBox = document.getElementById('detail-hold');
|
||||
var HOLD_DAYS = 90;
|
||||
|
||||
function renderHold() {
|
||||
if (!holdBox || !currentEvent) return;
|
||||
if (window.APP_ROLE !== 'admin') { holdBox.hidden = true; return; }
|
||||
holdBox.hidden = false;
|
||||
holdBox.textContent = '';
|
||||
|
||||
var until = currentEvent.hold_until;
|
||||
var held = typeof until === 'number' && until * 1000 > Date.now();
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = held ? 'fp-btn' : 'detail-link-btn';
|
||||
btn.textContent = held ? '보존 보류 해제' : '증거 보존 (' + HOLD_DAYS + '일)';
|
||||
btn.setAttribute('aria-label', held
|
||||
? '증거 보존 보류를 해제합니다. 이후 보존기간이 지난 스냅샷은 정리 대상이 됩니다.'
|
||||
: '이 사건의 스냅샷을 ' + HOLD_DAYS + '일간 보존기간 정리에서 제외합니다.');
|
||||
btn.addEventListener('click', function () {
|
||||
btn.disabled = true;
|
||||
post('/hold', { days: held ? 0 : HOLD_DAYS })
|
||||
.then(function (res) { return res ? res.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
currentEvent.hold_until = d.hold_until;
|
||||
renderHold();
|
||||
renderMeta();
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
var p = document.createElement('p');
|
||||
p.className = 'detail-action-note is-error';
|
||||
p.textContent = '보존 보류 처리에 실패했습니다.';
|
||||
holdBox.appendChild(p);
|
||||
});
|
||||
});
|
||||
holdBox.appendChild(btn);
|
||||
|
||||
var note = document.createElement('p');
|
||||
note.className = 'detail-action-note';
|
||||
note.textContent = held
|
||||
? '보존 보류 중 — ' + new Date(until * 1000).toLocaleDateString('ko-KR')
|
||||
+ '까지 이 사건의 사진이 자동 삭제되지 않습니다.'
|
||||
: '조사가 진행 중이면 보존을 걸어두세요. 걸지 않으면 보존기간이 지난 사진은 자동으로 지워집니다.';
|
||||
holdBox.appendChild(note);
|
||||
}
|
||||
|
||||
function renderMeta() {
|
||||
var ev = currentEvent || {};
|
||||
meta.textContent = '';
|
||||
|
|
@ -374,6 +430,7 @@
|
|||
renderHead();
|
||||
renderMeta();
|
||||
renderActions();
|
||||
renderHold();
|
||||
renderLinks();
|
||||
renderSnapshots(data.snapshots);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ use App\Auth\PhpSessionStore;
|
|||
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
|
||||
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
|
||||
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
|
||||
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
|
||||
PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository,
|
||||
PdoAppStateRepository};
|
||||
use App\Ingest\IngestController;
|
||||
use App\Api\{ConfigController, HeartbeatController};
|
||||
use App\Auth\{AuthController, CsrfController};
|
||||
|
|
@ -93,6 +94,7 @@ $handovers = new PdoHandoverRepository($pdo);
|
|||
$risks = new PdoRiskControlRepository($pdo);
|
||||
$metrics = new PdoDeviceMetricRepository($pdo);
|
||||
$actions = new PdoActionRepository($pdo);
|
||||
$appState = new PdoAppStateRepository($pdo);
|
||||
|
||||
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
|
||||
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
|
||||
|
|
@ -101,13 +103,13 @@ $auth = new AuthController($users, $audit, $rate);
|
|||
$csrfCtl = new CsrfController();
|
||||
$list = new EventListController($events, $snaps, $sess);
|
||||
$detail = new EventDetailController($events, $snaps, $audit, $sess);
|
||||
$proxy = new SnapshotProxy($snaps, $events, $sess);
|
||||
$proxy = new SnapshotProxy($snaps, $events, $sess, $audit);
|
||||
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
|
||||
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg);
|
||||
$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg, $appState);
|
||||
$heatmap = new HeatmapController($events, $sess);
|
||||
$fpStats = new FpStatsController($events, $sess);
|
||||
$report = new ReportController($events, $sess);
|
||||
$export = new ExportController($events, $snaps, $sess);
|
||||
$export = new ExportController($events, $snaps, $sess, $audit);
|
||||
$actionsCtl = new ActionsController($actions, $events, $sess);
|
||||
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
|
||||
$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess);
|
||||
|
|
@ -132,6 +134,7 @@ $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
|
|||
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
|
||||
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
|
||||
$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
|
||||
$r->add('POST', '/events/{id}/hold', fn(Request $q, array $p) => $detail->hold($q, $p));
|
||||
$r->add('GET', '/api/zone-presets', fn(Request $q) => $zoneCtl->presets($q));
|
||||
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
|
||||
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
|
||||
|
|
|
|||
|
|
@ -151,6 +151,11 @@ CREATE TABLE move_events (
|
|||
acknowledged_at BIGINT UNSIGNED NULL,
|
||||
false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
|
||||
false_positive_at BIGINT UNSIGNED NULL,
|
||||
-- 증거 보존 보류. 이 시각까지는 보존기간 정리가 이 사건의 스냅샷을 지우지 않는다.
|
||||
-- 중대재해 조사는 몇 주~몇 달인데 스냅샷 기본 보존은 7일이라, 보류가 없으면 "사고 후
|
||||
-- 원인 분석"이라는 목적 자체가 8일째에 사라진다. 자동 연장이 아니라 명시적 조작으로만
|
||||
-- 걸리고(자동이면 결국 전량 보존이 되어 프라이버시 약속이 무너진다) 감사에 남는다.
|
||||
hold_until BIGINT UNSIGNED NULL,
|
||||
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
||||
PRIMARY KEY (event_id),
|
||||
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
||||
|
|
@ -160,6 +165,7 @@ CREATE TABLE move_events (
|
|||
KEY ix_events_occurred_at (occurred_at),
|
||||
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
||||
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
|
||||
KEY ix_events_hold (hold_until), -- 보존 정리가 보류된 건만 빠르게 걸러낸다
|
||||
KEY ix_events_device (device_id),
|
||||
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
||||
CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||
|
|
@ -414,6 +420,23 @@ CREATE TABLE move_device_metrics (
|
|||
KEY ix_device_metrics_device_ts (device_id, ts)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 감시자의 생존 기록.
|
||||
--
|
||||
-- 죽은 노드는 스스로 신고할 수 없어서 크론(tools/dispatch_alerts.php)이 하트비트의 부재로
|
||||
-- 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이
|
||||
-- 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
|
||||
-- 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하므로, 크론이 매 실행마다 자기
|
||||
-- 시각을 여기 남기고 대시보드(/api/devices)가 그 나이를 본다.
|
||||
--
|
||||
-- 범용 키-값이지만 용도를 넓히지 말 것: 설정은 config.php, 임계값은 move_thresholds 가 있다.
|
||||
-- 여기 들어갈 것은 "프로세스가 언제 살아 있었는가" 뿐이다.
|
||||
CREATE TABLE move_app_state (
|
||||
k VARCHAR(64) NOT NULL,
|
||||
v VARCHAR(255) NOT NULL,
|
||||
updated_at BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (k)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ===========================================================================
|
||||
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
|
||||
-- do NOT copy the statements below by hand — they are SQL comments and will not
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use App\Config;
|
|||
use App\Http\{Request, Response};
|
||||
use App\Auth\SessionStore;
|
||||
use App\Security\SiteScope;
|
||||
use App\Repository\{DeviceRepository, DeviceMetricRepository};
|
||||
use App\Repository\{DeviceRepository, DeviceMetricRepository, AppStateRepository};
|
||||
|
||||
/**
|
||||
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
|
||||
|
|
@ -27,13 +27,22 @@ final class DeviceStatusController
|
|||
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
|
||||
private const OFFLINE_AFTER_S = 120;
|
||||
|
||||
/**
|
||||
* 통보 디스패처(크론)가 이 시간 넘게 자기 실행을 남기지 않았으면 멈춘 것으로 본다.
|
||||
* 크론은 1분 주기이므로 5배 여유 — 공유호스팅에서 한두 주기가 밀리는 것과 진짜로
|
||||
* 죽은 것을 가른다. 이 판정이 필요한 이유는 크론이 죽으면 통보·5분 확대·장치 이상
|
||||
* 발행이 한꺼번에 조용히 멈추고, 그 침묵이 "사고 없음"과 똑같이 보이기 때문이다.
|
||||
*/
|
||||
private const DISPATCHER_STALE_AFTER_S = 300;
|
||||
|
||||
/** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */
|
||||
private const SERIES_WINDOW_S = 86400;
|
||||
private const SERIES_LIMIT = 3000;
|
||||
|
||||
public function __construct(
|
||||
private DeviceRepository $devices, private SessionStore $sess,
|
||||
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
|
||||
private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null,
|
||||
private ?AppStateRepository $state = null
|
||||
) {}
|
||||
|
||||
public function json(Request $req, int $now): Response
|
||||
|
|
@ -63,7 +72,11 @@ final class DeviceStatusController
|
|||
];
|
||||
}, $this->devices->listStatus($site));
|
||||
|
||||
return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200);
|
||||
return Response::json([
|
||||
'devices' => $devices,
|
||||
'storage' => $this->storage(),
|
||||
'dispatcher' => $this->dispatcher($now),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -122,4 +135,35 @@ final class DeviceStatusController
|
|||
'low' => $freeMb < $minFreeMb,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 통보 디스패처의 생존.
|
||||
*
|
||||
* 이 판정만 브라우저가 읽어야 하는 이유: 크론이 죽으면 크론이 발행하는 어떤 경고도
|
||||
* 나오지 않는다. device_fault 는 하트비트의 부재로 노드를 판정하지만, 그 판정을 하는
|
||||
* 주체가 바로 이 크론이다. 그래서 크론의 부재는 크론 밖에서만 보인다.
|
||||
*
|
||||
* never = 아직 한 번도 안 돌았다(설치 직후 크론 미등록). 이걸 stale 과 구별하는 이유는
|
||||
* 조치가 다르기 때문이다 — 전자는 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다.
|
||||
*
|
||||
* @return array{last_run_at:int|null, seconds_since:int|null, stale:bool, stale_after_s:int}
|
||||
*/
|
||||
private function dispatcher(int $now): array
|
||||
{
|
||||
$row = null;
|
||||
try {
|
||||
$row = $this->state?->get('dispatch_alerts.last_run');
|
||||
} catch (\Throwable) {
|
||||
// 표가 아직 없는(마이그레이션 전) 배포에서 상태 패널 전체가 죽지 않게 한다.
|
||||
// 그 경우 "한 번도 안 돌았다"로 보이고, 그것이 실제로 맞는 상태다.
|
||||
}
|
||||
$last = $row !== null ? (int)$row['updated_at'] : null;
|
||||
$since = $last !== null ? max(0, $now - $last) : null;
|
||||
return [
|
||||
'last_run_at' => $last,
|
||||
'seconds_since' => $since,
|
||||
'stale' => $since === null || $since > self::DISPATCHER_STALE_AFTER_S,
|
||||
'stale_after_s' => self::DISPATCHER_STALE_AFTER_S,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
|||
|
||||
final class EventDetailController
|
||||
{
|
||||
/** 1년. 이보다 긴 보존이 필요하면 사건을 따로 내보내는 것이 맞지, 사진을 계속
|
||||
* 들고 있는 것이 맞지 않다. 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. */
|
||||
private const MAX_HOLD_DAYS = 365;
|
||||
|
||||
public function __construct(
|
||||
private EventRepository $events, private SnapshotRepository $snaps,
|
||||
private AuditRepository $audit, private SessionStore $sess
|
||||
|
|
@ -68,4 +72,44 @@ final class EventDetailController
|
|||
$this->audit->append($user, 'false_positive', "event:$id", [], []);
|
||||
return Response::json(['ok' => true], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/{id}/hold — 증거 보존 보류 (본문: {"days": 1..365} 또는 {"days": 0} 해제).
|
||||
*
|
||||
* 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙
|
||||
* 자체는 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진 증거가
|
||||
* 사라졌다. "사고 후 원인 분석" 이라는 우리가 내건 목적이 8일째에 스스로 지워지는 상태.
|
||||
*
|
||||
* 자동 연장이 아닌 이유는 확인(acknowledge)에 얹으면 사실상 전량 보존이 되어 프라이버시
|
||||
* 약속이 무너지기 때문이고, admin(manage_compliance)으로 막은 이유는 보존기간 연장이
|
||||
* 운영 편의가 아니라 규정 판단이기 때문이다. 누가 언제 얼마나 늘렸는지가 감사에 남는다 —
|
||||
* 노조에 "전부 지웁니다, 단 사고 확정 건은 조사 종료까지, 기록과 함께" 라고 말할 수 있는
|
||||
* 근거가 이 한 행이다.
|
||||
*/
|
||||
public function hold(Request $req, array $params): Response
|
||||
{
|
||||
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
||||
if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
|
||||
return Response::json(['error' => 'forbidden'], 403);
|
||||
}
|
||||
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
||||
return Response::json(['error' => 'csrf'], 403);
|
||||
}
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$event = $this->events->find($id);
|
||||
if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
|
||||
|
||||
$raw = $req->json()['days'] ?? null;
|
||||
if (!is_numeric($raw)) return Response::json(['error' => 'bad_days'], 400);
|
||||
$days = (int)$raw;
|
||||
if ($days < 0 || $days > self::MAX_HOLD_DAYS) return Response::json(['error' => 'bad_days'], 400);
|
||||
|
||||
$until = $days === 0 ? null : time() + $days * 86400;
|
||||
if (!$this->events->setHold($id, $until)) return Response::json(['error' => 'not_found'], 404);
|
||||
|
||||
$user = (string)$this->sess->get('username');
|
||||
$this->audit->append($user, $until === null ? 'evidence_hold_release' : 'evidence_hold',
|
||||
"event:$id", ['hold_until' => $event['hold_until'] ?? null], ['hold_until' => $until]);
|
||||
return Response::json(['ok' => true, 'hold_until' => $until], 200);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace App\Dashboard;
|
|||
|
||||
use App\Http\{Request, Response};
|
||||
use App\Auth\{SessionStore, Rbac};
|
||||
use App\Repository\{EventRepository, SnapshotRepository};
|
||||
use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
|
||||
|
||||
/**
|
||||
* GET /api/export/labels?days=N -> JSONL download.
|
||||
|
|
@ -29,7 +29,7 @@ final class ExportController
|
|||
|
||||
public function __construct(
|
||||
private EventRepository $events, private SnapshotRepository $snaps,
|
||||
private SessionStore $sess
|
||||
private SessionStore $sess, private ?AuditRepository $audit = null
|
||||
) {}
|
||||
|
||||
public function labels(Request $req): Response
|
||||
|
|
@ -69,6 +69,14 @@ final class ExportController
|
|||
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
// 대량 반출은 남긴다. 이 한 번의 호출이 최대 2만 건의 사건과 그에 딸린 스냅샷 uuid
|
||||
// 전부를 파일 하나로 내보내는데, 기록이 없으면 "지난달에 우리 현장 데이터가 통째로
|
||||
// 나간 적 있습니까"에 답할 수 없다. 열람 감사(snapshot_view)와 짝이며, 이쪽은 드물게
|
||||
// 일어나므로 무조건 한 행씩 남긴다. 반출 사실을 남기는 것이지 반출을 막는 장치가
|
||||
// 아니므로, 기록이 실패해도 파일은 내보낸다.
|
||||
$this->audit?->append((string)$this->sess->get('username'), 'label_export',
|
||||
'export:labels', [], ['days' => $days, 'rows' => count($lines), 'site' => $site]);
|
||||
|
||||
$body = implode("\n", $lines) . ($lines ? "\n" : '');
|
||||
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
|
||||
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ namespace App\Dashboard;
|
|||
use App\Http\{Request, Response};
|
||||
use App\Auth\SessionStore;
|
||||
use App\Security\SiteScope;
|
||||
use App\Repository\{SnapshotRepository, EventRepository};
|
||||
use App\Repository\{SnapshotRepository, EventRepository, AuditRepository};
|
||||
|
||||
final class SnapshotProxy
|
||||
{
|
||||
/**
|
||||
* 세션당 기억할 열람 uuid 수. 넘으면 오래된 것부터 버리고, 버려진 uuid 를 다시 보면
|
||||
* 감사에 한 줄 더 남는다 — 넘치면 **덜 기록**이 아니라 **더 기록**하는 방향으로 틀어진다.
|
||||
*/
|
||||
private const VIEWED_MEMO_CAP = 500;
|
||||
|
||||
public function __construct(
|
||||
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess
|
||||
private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess,
|
||||
private ?AuditRepository $audit = null
|
||||
) {}
|
||||
|
||||
public function serve(Request $req, array $params): Response
|
||||
|
|
@ -30,6 +37,7 @@ final class SnapshotProxy
|
|||
return Response::json(['error' => 'not_found'], 404);
|
||||
}
|
||||
}
|
||||
$this->recordView($uuid);
|
||||
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event
|
||||
// list — which rebuilds every <img src=/snap/{uuid}> on each 5s poll — re-downloads every
|
||||
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
|
||||
|
|
@ -38,4 +46,42 @@ final class SnapshotProxy
|
|||
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
|
||||
['Cache-Control' => 'private, max-age=86400, immutable']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자 사진을 누가 언제 봤는지 남긴다.
|
||||
*
|
||||
* 이 시스템은 쓰기를 전부 감사한다 — 임계값 한 번 바꾼 것까지 해시 체인에 들어간다.
|
||||
* 그런데 읽기는 하나도 남지 않았다. 관리자가 조합원 사진 200장을 열어봐도 흔적이 없고,
|
||||
* 교섭에서 "안 봤습니다"는 증명이 아니라 주장이다. 감시 시스템과 안전 시스템을 가르는
|
||||
* 선이 정확히 여기라서, 나머지 프라이버시 설계(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가
|
||||
* 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
|
||||
*
|
||||
* 세션 안에서 같은 uuid 는 한 번만 기록한다. 목록 화면은 5초마다 <img> 를 다시 그리고
|
||||
* (브라우저 캐시가 대부분 막지만 시크릿 창·캐시 해제는 그대로 들어온다), 감사 추가는
|
||||
* 체인 꼬리에 행 잠금을 잡는 작업이라 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다.
|
||||
* "이 로그인 세션에서 이 사진을 봤다"는 노조가 묻는 질문에 답하기 충분하다.
|
||||
*
|
||||
* 기록 실패가 사진 열람을 막지는 않는다 — 감사가 안전 기능을 죽이면 안 된다. 다만 그
|
||||
* 경우 메모에 넣지 않아서 다음 요청이 다시 시도한다.
|
||||
*/
|
||||
private function recordView(string $uuid): void
|
||||
{
|
||||
if ($this->audit === null) return;
|
||||
$seen = $this->sess->get('snap_viewed');
|
||||
$seen = is_array($seen) ? $seen : [];
|
||||
if (in_array($uuid, $seen, true)) return;
|
||||
|
||||
try {
|
||||
$this->audit->append((string)$this->sess->get('username'), 'snapshot_view',
|
||||
"snapshot:$uuid", [], []);
|
||||
} catch (\Throwable) {
|
||||
return; // 메모에 안 넣는다 → 다음 요청에서 재시도
|
||||
}
|
||||
|
||||
$seen[] = $uuid;
|
||||
if (count($seen) > self::VIEWED_MEMO_CAP) {
|
||||
$seen = array_slice($seen, -self::VIEWED_MEMO_CAP);
|
||||
}
|
||||
$this->sess->set('snap_viewed', $seen);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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.
|
||||
*/
|
||||
public function markFalsePositive(int $id, string $user): bool;
|
||||
/**
|
||||
* 증거 보존 보류. $until 까지 이 사건의 스냅샷은 보존기간 정리에서 제외된다.
|
||||
* null 을 주면 보류를 해제한다. 존재하지 않는 사건이면 false.
|
||||
*
|
||||
* 자동이 아니라 명시적 조작인 이유: 확인(acknowledge)만으로 자동 연장하면 사실상 전량
|
||||
* 보존이 되어 "사고 순간만, 7일만" 이라는 프라이버시 약속이 무너진다. 반대로 보류가
|
||||
* 아예 없으면 조사가 시작되기도 전에 증거가 사라진다. 그 사이를 사람의 판단으로 가른다.
|
||||
*/
|
||||
public function setHold(int $id, ?int $until): bool;
|
||||
|
||||
/**
|
||||
* FP-rate aggregation for the stats panel: per (camera_id, event_type) totals and
|
||||
* false-positive counts since $sinceOccurredAt, only groups with at least one FP.
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
public function setHold(int $id, ?int $until): bool
|
||||
{
|
||||
// acknowledge() 와 같은 이유로 존재 확인을 따로 한다 — MySQL PDO 는 기본적으로
|
||||
// '변경된 행'을 세므로, 같은 값으로 다시 보류하면 rowCount() 가 0 이 된다.
|
||||
$exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
|
||||
$exists->execute([$id]);
|
||||
if ($exists->fetchColumn() === false) return false;
|
||||
|
||||
$st = $this->pdo->prepare('UPDATE move_events SET hold_until = ? WHERE event_id = ?');
|
||||
$st->bindValue(1, $until, $until === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
|
||||
$st->bindValue(2, $id, PDO::PARAM_INT);
|
||||
$st->execute();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function acknowledge(int $id, string $user): bool
|
||||
{
|
||||
// Existence is checked separately (rather than trusting UPDATE's
|
||||
|
|
|
|||
|
|
@ -121,16 +121,28 @@ final class PdoSnapshotRepository implements SnapshotRepository
|
|||
return $id === false ? null : (int)$id;
|
||||
}
|
||||
|
||||
public function purgeOlderThan(int $cutoff, int $limit = 500): int
|
||||
public function purgeOlderThan(int $cutoff, int $limit = 500, ?int $now = null): int
|
||||
{
|
||||
$now ??= time();
|
||||
// Bounded batch: a single request must not stall unlinking thousands of files on a
|
||||
// shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan
|
||||
// row (file gone, row left) is harmless; an orphan file (row gone, file left) is the
|
||||
// PIPA leak we are closing, so never delete the row before its file.
|
||||
// 보존 보류(legal hold)된 사건의 스냅샷은 나이가 지나도 남긴다. 보류가 없던 동안
|
||||
// 이 삭제는 나이 하나만 봤고, 그래서 중대재해 조사가 시작되기도 전에(스냅샷 기본
|
||||
// 7일) 사진 증거가 사라졌다 — 우리가 내세운 목적이 스스로 지워지는 상태였다.
|
||||
// LEFT JOIN 인 이유: 이벤트 행이 먼저 정리된 고아 스냅샷은 보류가 아니라 잔여물이고,
|
||||
// 그건 지우는 쪽이 맞다(hold_until 이 NULL 로 나와 자연히 대상이 된다).
|
||||
$sel = $this->pdo->prepare(
|
||||
'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?');
|
||||
'SELECT s.snapshot_id, s.file_uuid
|
||||
FROM move_event_snapshots s
|
||||
LEFT JOIN move_events e ON e.event_id = s.event_id
|
||||
WHERE s.created_at < ?
|
||||
AND (e.hold_until IS NULL OR e.hold_until <= ?)
|
||||
LIMIT ?');
|
||||
$sel->bindValue(1, $cutoff, PDO::PARAM_INT);
|
||||
$sel->bindValue(2, $limit, PDO::PARAM_INT);
|
||||
$sel->bindValue(2, $now, PDO::PARAM_INT);
|
||||
$sel->bindValue(3, $limit, PDO::PARAM_INT);
|
||||
$sel->execute();
|
||||
$rows = $sel->fetchAll();
|
||||
if (!$rows) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ interface SnapshotRepository
|
|||
public function latestForCamera(string $cameraId): ?array;
|
||||
|
||||
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
|
||||
* worker images must not be kept forever. Returns the number purged. */
|
||||
* worker images must not be kept forever. Returns the number purged.
|
||||
* 증거 보존 보류(move_events.hold_until)가 걸린 사건의 스냅샷은 나이와 무관하게 남긴다 —
|
||||
* 조사가 끝나기 전에 증거가 사라지면 이 제품의 목적 자체가 사라진다. */
|
||||
public function purgeOlderThan(int $cutoff): int;
|
||||
|
||||
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ render_header('이벤트 상세');
|
|||
<!-- 판정은 증거를 본 화면에서 끝난다. 예전에는 여기서 판단하고 목록으로 되돌아가
|
||||
그 행을 다시 찾아 눌러야 했다 — 판단하는 자리에 판단 도구가 없었다. -->
|
||||
<div class="detail-actions" id="detail-actions" hidden></div>
|
||||
<div class="detail-actions" id="detail-hold" hidden></div>
|
||||
</header>
|
||||
|
||||
<div class="detail-links">
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ render_header('이벤트');
|
|||
<!-- 스냅샷 저장 공간이 차면 이미지 기록이 조용히 실패한다 (이벤트는 계속 쌓인다).
|
||||
증거가 사라지고 있다는 사실을 대시보드가 먼저 말해야 한다. -->
|
||||
<div id="storage-warn" class="storage-warn" role="status" hidden></div>
|
||||
<div id="dispatch-warn" class="storage-warn" role="status" hidden></div>
|
||||
<!-- Catch-up line for the operator who just sat down: what the last 24h held while
|
||||
nobody was watching. Hidden until the numbers arrive (an empty banner is noise). -->
|
||||
<div id="day-brief" class="day-brief" hidden></div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ if (!function_exists('view_url')) {
|
|||
if (!function_exists('render_header')) {
|
||||
function render_header(string $title): void {
|
||||
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
|
||||
// 표시용일 뿐 권한 판정이 아니다. 보존 보류 버튼을 admin 에게만 보여주려고 쓰는데,
|
||||
// 실제 차단은 EventDetailController 의 manage_compliance 검사가 한다 — 여기를
|
||||
// 조작해도 서버가 403 을 낸다.
|
||||
$role = is_string($_SESSION['role'] ?? null) ? $_SESSION['role'] : null;
|
||||
// Which primary nav tab is active is derived from the page title so no view
|
||||
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
||||
// 페이지 이름은 nav 라벨 · <h1> · <title> 세 곳이 같은 문자열을 쓴다. 예전에는
|
||||
|
|
@ -51,6 +55,7 @@ if (!function_exists('render_header')) {
|
|||
<link rel="stylesheet" href="<?= view_url('/assets/app.css') ?>">
|
||||
<script nonce="<?= view_e(\App\Security\Csp::nonce()) ?>">
|
||||
window.APP_BASE =<?= json_encode(defined('APP_BASE') ? APP_BASE : '', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
window.APP_ROLE = <?= json_encode($role ?? '', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
</script>
|
||||
</head>
|
||||
|
|
|
|||
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,
|
||||
false_positive_by TEXT,
|
||||
false_positive_at INTEGER,
|
||||
hold_until INTEGER,
|
||||
received_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE move_app_state (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE move_zones (
|
||||
zone_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
|
|
|
|||
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 /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
|
||||
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
|
||||
'POST /events/{id}/hold',
|
||||
'GET /api/zones/{camera}', 'GET /api/zone-presets',
|
||||
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
|
||||
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ final class InMemoryEventRepository implements EventRepository {
|
|||
}
|
||||
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
|
||||
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; $this->rows[$id]['acknowledged_at'] = $this->rows[$id]['acknowledged_at'] ?? time(); return true; }
|
||||
public function setHold(int $id, ?int $until): bool {
|
||||
if (!isset($this->rows[$id])) return false;
|
||||
$this->rows[$id]['hold_until'] = $until;
|
||||
return true;
|
||||
}
|
||||
public function markFalsePositive(int $id, string $user): bool {
|
||||
if (!isset($this->rows[$id])) return false;
|
||||
$r = $this->rows[$id];
|
||||
|
|
@ -210,11 +215,14 @@ final class InMemoryEventRepository implements EventRepository {
|
|||
}
|
||||
final class InMemorySnapshotRepository implements SnapshotRepository {
|
||||
public array $rows = []; public array $paths = []; public array $created = [];
|
||||
/** 실제 파일이 있어야 하는 테스트(SnapshotProxy 는 is_file() 을 본다)가 쓸 디렉터리.
|
||||
* 기본값은 예전 그대로 /tmp — 경로 문자열만 쓰던 기존 테스트가 그대로 돈다. */
|
||||
public function __construct(private string $dir = '/tmp') {}
|
||||
public function add(int $e, string $uuid, string $hash): void {
|
||||
// Mirror the DB's UNIQUE (event_id, content_hash): a re-sent event's identical bytes
|
||||
// collide instead of duplicating, so the dedup recovery path is idempotent.
|
||||
foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; }
|
||||
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time();
|
||||
$this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = rtrim($this->dir, '/\\') . "/$uuid.jpg"; $this->created[$uuid] = time();
|
||||
}
|
||||
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
|
||||
public function thumbUuidsFor(array $eventIds): array {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ require __DIR__ . '/bootstrap.php';
|
|||
|
||||
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
|
||||
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
|
||||
PdoDeviceRepository, PdoDeviceMetricRepository};
|
||||
PdoDeviceRepository, PdoDeviceMetricRepository, PdoAppStateRepository};
|
||||
|
||||
[$cfg, $pdo] = tool_boot();
|
||||
$args = tool_args($argv);
|
||||
|
|
@ -179,6 +179,24 @@ if (!$dryRun) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 5. 생존 기록 ---------------------------------------------------------------------
|
||||
// 여기까지 왔다는 것은 이 프로세스가 돌았다는 뜻이다. 통보·확대·장치 이상 발행이 전부 이 한
|
||||
// 프로세스에 걸려 있어서, 크론이 멈추면 화면은 그냥 조용해지고 그 침묵은 "사고 없음"과
|
||||
// 구별되지 않는다. 죽은 노드를 하트비트의 부재로 판정하는 바로 그 논리를 우리 자신에게도
|
||||
// 적용한다 — 감시자의 생존은 감시자가 아닌 쪽(대시보드)이 읽는다.
|
||||
//
|
||||
// 단계별 예외 처리 밖에, 마지막에 무조건 남긴다: 어느 단계가 실패했더라도 프로세스는 살아
|
||||
// 있었고, 여기서 구별하려는 것은 "통보가 틀렸다"가 아니라 "아무도 안 돌고 있다"이다.
|
||||
// dry-run 은 남기지 않는다 — 사람이 손으로 확인해 본 것을 크론이 도는 증거로 삼으면,
|
||||
// 크론이 죽은 뒤에도 화면이 초록으로 남는다.
|
||||
if (!$dryRun) {
|
||||
try {
|
||||
(new PdoAppStateRepository($pdo))->set('dispatch_alerts.last_run', (string)$now, $now);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, date('c') . ' 생존 기록 실패(통보에는 영향 없음): ' . $e->getMessage() . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%d%s\n",
|
||||
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],
|
||||
|
|
|
|||
|
|
@ -246,3 +246,54 @@ def test_two_persons_get_independent_smoothing():
|
|||
# state leaked between tracks.
|
||||
assert left_nose_x < 300
|
||||
assert right_nose_x > 700
|
||||
|
||||
|
||||
def test_overstay_goes_out_without_evidence_while_the_same_batch_keeps_it():
|
||||
"""체류 상한 초과에는 사진이 붙지 않는다 — 같은 배치의 구역 침입에는 붙는 채로.
|
||||
|
||||
상수만 검사하면 배선이 끊겨도 초록이 뜬다(이 프로젝트가 감사마다 잡아온 모양이다).
|
||||
그래서 실제 run_once 를 태워, 한 번의 전송에서 한쪽은 증거를 들고 다른 쪽은 안 드는지를
|
||||
본다. 체류 초과의 발화 조건은 시간 경과 하나뿐이라, 여기 사진이 붙으면 정상 작업자가
|
||||
규정대로 일하는 동안 주기적으로 본인 사진이 서버로 가는 시스템이 된다.
|
||||
"""
|
||||
from edge.detectors.zone import Zone
|
||||
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
|
||||
|
||||
class _SnapSink:
|
||||
def __init__(self): self.sent = []
|
||||
def try_send(self, event, snapshots):
|
||||
self.sent.append((event, snapshots)); return True
|
||||
|
||||
poly = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] # 프레임 전체
|
||||
zone = Zone("z1", "restricted", poly, {"max_dwell_s": 1.0})
|
||||
|
||||
src = _Source()
|
||||
pose = _ScriptedPose([[_person_at(320)], [_person_at(320)]])
|
||||
engine = EventEngine(
|
||||
FallDetector(FallConfig(prone_confirm_s=999)),
|
||||
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
||||
GhostIncidentDetector(GhostConfig()),
|
||||
ZoneIntrusionDetector([zone], ZoneConfig()),
|
||||
EngineConfig(camera_id="cam-01"),
|
||||
# 기본 설정 그대로. 바꾸는 것은 존 규칙(현장 설정)인 max_dwell_s 뿐이다.
|
||||
occupancy=OccupancyWatchdog(OccupancyConfig()))
|
||||
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
|
||||
|
||||
# 트래커·스무더는 프레임 간에 공유한다 — 체류는 "같은 트랙이 계속 있었는가"라서,
|
||||
# 매 호출 새 트래커를 주면 실제와 다른 조건에서 통과할 수 있다.
|
||||
tracker, pool = IouTracker(), TrackSmootherPool()
|
||||
times = iter([0.0, 1.5])
|
||||
def now_fn(): return next(times)
|
||||
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
|
||||
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
|
||||
|
||||
by_type = {e.event_type: s for e, s in sink.sent}
|
||||
assert EventType.OVERSTAY in by_type, "체류 상한 초과가 발화하지 않았습니다 — 테스트가 무의미합니다"
|
||||
assert by_type[EventType.OVERSTAY] == [], \
|
||||
"체류 상한 초과에 사진이 붙었습니다. 오래 있었다는 이유로 작업자 사진이 남습니다."
|
||||
|
||||
# 대조군: 같은 파이프라인에서 진짜 사건에는 증거가 붙어 있어야 한다. 이게 없으면
|
||||
# 위 단언은 "증거 기능이 통째로 죽었다"로도 통과한다.
|
||||
assert EventType.ZONE_RESTRICTED in by_type, "구역 침입이 발화하지 않았습니다"
|
||||
assert by_type[EventType.ZONE_RESTRICTED], \
|
||||
"구역 침입에서 증거가 사라졌습니다 — 사고 조사에서 볼 것이 없어집니다."
|
||||
|
|
|
|||
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