fix(audit): 1.2.0 4차 재감사 — 확정 23건 전량 해소

4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다.

이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는**
결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나,
값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다.

CRITICAL 4건:

R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now
기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서
초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는
회귀 테스트를 넣었다.

R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50]
이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은
2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달
불가였다.

R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어
400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다.

R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시
prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이
진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다.
ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다.

HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두
희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한
사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던
문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개
누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프.

BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번
실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션
목록도 같은 이유로 테스트를 붙였다.

MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된
그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다.
데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인
넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져
눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다.
문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을
소개서 감지 표에 넣었다.

검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건.
dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
유창욱 2026-09-05 23:11:57 +09:00
parent 4bfb7e0274
commit 93f896af87
25 changed files with 779 additions and 76 deletions

View file

@ -6,7 +6,7 @@
--- ---
## 1.2.0 — 2026-09-04 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋 ## 1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋
AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는 AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 **새 추론 모델 없이** 지금 있는
자세추정·구역·트랙 이력만으로 구현했습니다. 자세추정·구역·트랙 이력만으로 구현했습니다.

View file

@ -16,7 +16,7 @@
| `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 | | `app.py` | `run_once` 파이프라인 (프레임→추론→추적→감지→스냅샷→전송). ambiguous 리셋·stale 필터 |
| `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget | | `engine.py` | `EventEngine` — 감지기 오케스트레이션, 쿨다운 dedup, 트랙 forget |
| `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) | | `tracking.py` | `IouTracker` — 전역 그리디 IoU 매칭, `ambiguous()` (교차 경합 트랙) |
| `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py` · `zone.py` · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) | | `detectors/` | `fall.py`(FALLING→FALLEN→NO_MOTION, sustained prone) · `immobility.py` · `ghost.py`(위험 상태 소실 + 높이 무장 → 화각 밖 추락) · `zone.py`(`elevated` 고소 표시) · `occupancy.py`(존 규칙: 2인1조/정원/체류상한 — Zone.rules 소비) |
| `smoothing.py` | 트랙별 키포인트 스무딩 풀 | | `smoothing.py` | 트랙별 키포인트 스무딩 풀 |
| `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter | | `snapshot.py` | RingBuffer · FaceBlurrer(PIPA) · Snapshotter |
| `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect | | `alert.py` / `serverclient.py` | HMAC+nonce POST, 커스텀 UA, no-redirect |
@ -28,6 +28,7 @@
| `beacon.py` | 현장 경광등·부저 (GPIO). **네트워크 없이 동작하는 유일한 통보 경로** — /api/config 의 `beacons` 로 설정 수신, 하드웨어 없으면 NullBeacon 으로 폴백 | | `beacon.py` | 현장 경광등·부저 (GPIO). **네트워크 없이 동작하는 유일한 통보 경로** — /api/config 의 `beacons` 로 설정 수신, 하드웨어 없으면 NullBeacon 으로 폴백 |
| `tuning.py` | 서버 임계값 → 살아 있는 감지기에 적용. 이전에는 `thresholds_from_config()` 가 파싱만 하고 버려서 관리자 변경이 장비에 닿지 않았다 | | `tuning.py` | 서버 임계값 → 살아 있는 감지기에 적용. 이전에는 `thresholds_from_config()` 가 파싱만 하고 버려서 관리자 변경이 장비에 닿지 않았다 |
| `detectors/integrity.py` | 카메라 화각 이동·가림·조도 저하 (`camera_moved`/`camera_obstructed`/`low_light`). 존 판정이 조용히 무의미해지는 실패 모드를 드러낸다 | | `detectors/integrity.py` | 카메라 화각 이동·가림·조도 저하 (`camera_moved`/`camera_obstructed`/`low_light`). 존 판정이 조용히 무의미해지는 실패 모드를 드러낸다 |
| `detectors/height.py` | 추락(`fall_from_height`) 1차 신호. **중심이 아니라 bbox 하단(y2)** 으로 판정 — 쭈그려 앉으면 중심은 내려가도 발은 그 자리다. `landed_from_drop`(착지 시각과 비교)이 핵심: now 로 비교하면 확정이 prone_confirm_s 만큼 늦어 기본값에서 영원히 거짓 |
| `detectors/pending.py` | 보호구·중장비 근접·화재 — 배선/계약 완료, **모델 미확보로 기본 비활성** (AGPL 아닌 가중치 + HEF 컴파일 필요) | | `detectors/pending.py` | 보호구·중장비 근접·화재 — 배선/계약 완료, **모델 미확보로 기본 비활성** (AGPL 아닌 가중치 + HEF 컴파일 필요) |
## server/ (PHP 8 + PDO — Cafe24) ## server/ (PHP 8 + PDO — Cafe24)
@ -37,6 +38,8 @@
- `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) · - `src/Alert/`: `AlertDispatcher`(어떤 통보를 낼지 결정 — 순수 로직, I/O 없음) ·
`Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 → `Channels`(email/webhook 발송; `local` 은 엣지가 자체 GPIO 로) · `EscalationWatch`(미확인 위급 →
`unacked_escalation` 이벤트 발행, event_uuid 로 멱등) · `unacked_escalation` 이벤트 발행, event_uuid 로 멱등) ·
`MultiDownWatch`(같은 구역 짧은 시간 2명 이상 쓰러짐 → `multi_down`; 값어치는 통보 문구에
있다 — Channels::guidance 의 "공기호흡기 없이 진입 금지") ·
`DeviceHealthWatch`(하트비트 부재 → `device_fault`, 지속 성능저하 → `monitoring_degraded`; `DeviceHealthWatch`(하트비트 부재 → `device_fault`, 지속 성능저하 → `monitoring_degraded`;
엣지가 낼 수 없는 두 유형을 서버가 발행. uuid 앵커 = 장애 구간 시작이라 장애당 1건) 엣지가 낼 수 없는 두 유형을 서버가 발행. uuid 앵커 = 장애 구간 시작이라 장애당 1건)
- `docs/deck/`: 경영진 보고 덱. `deck.json`(내용) + `build_deck.py`(생성기) + `clips/`(3초 재현 - `docs/deck/`: 경영진 보고 덱. `deck.json`(내용) + `build_deck.py`(생성기) + `clips/`(3초 재현
@ -44,6 +47,9 @@
발주 명세는 `docs/영상-브리프.md`. 발주 명세는 `docs/영상-브리프.md`.
- `docs/images/`: 소개서·덱 공용 도판 11장(원본은 `image/`, 최적화본이 여기). - `docs/images/`: 소개서·덱 공용 도판 11장(원본은 `image/`, 최적화본이 여기).
배치 매니페스트 `docs/소개서-이미지.json`. 배치 매니페스트 `docs/소개서-이미지.json`.
- `src/Api/ZonePresetCatalog.php`: 용도별 구역 프리셋 6종(밀폐공간·고소작업·프레스협착·
출입금지·지게차통로·화기작업). 새 감지 기능이 아니라 기존 규칙에 현장 어휘를 붙인 것.
`GET /api/zone-presets` (ZoneConfigController::presets, view_events 게이트).
- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·

View file

@ -152,7 +152,7 @@
}, },
{ {
"head": "근무태도 기능이 없습니다", "head": "근무태도 기능이 없습니다",
"body": "감지 17종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다." "body": "감지 20종 전부가 위험 상태입니다. 자리 비움·작업 속도는 만들 수 있는 기능 자체가 없습니다."
} }
], ],
"note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다." "note": "존재하지 않는 데이터는 열람될 수 없고, 유출될 수 없고, 목적 외로 쓰일 수 없습니다. 접근 통제는 되돌릴 수 있지만 미수집은 되돌릴 수 없습니다."
@ -169,9 +169,9 @@
"eyebrow": "현재 상태", "eyebrow": "현재 상태",
"title": "지금 어디까지 와 있습니까", "title": "지금 어디까지 와 있습니까",
"done": { "done": {
"head": "완료 — 1.1.0 납품 버전", "head": "완료 — 1.2.0 납품 버전",
"items": [ "items": [
"감지 17종 · 통보 · 에스컬레이션 · 사고조사 패키지", "감지 20종(추락·동시 다발 포함) · 통보 · 에스컬레이션 · 사고조사 패키지",
"자동 테스트 548건 통과 (엣지 308 · 서버 240)", "자동 테스트 548건 통과 (엣지 308 · 서버 240)",
"납품 점검 43건 + 재감사 12건 전량 해소 — 판정 적합", "납품 점검 43건 + 재감사 12건 전량 해소 — 판정 적합",
"폐쇄망 설치 패키지 · 역할별 사용설명서" "폐쇄망 설치 패키지 · 역할별 사용설명서"

View file

@ -1,7 +1,8 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-09-04, 1.1.0): 적합 — 오프하드웨어 항목 전량 해소.** > **판정 (2026-09-05, 1.2.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** 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조. > 잔여는 실장비(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
@ -433,6 +434,94 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
--- ---
## 4차 재감사 — 1.2.0 추락·동시 다발·프리셋 (2026-09-05)
범위: 1.1.0 이후 변경 전량. 4개 관점 병렬 감사, 각 발견을 반증 시도해 확정.
**이번 감사의 요지: 구현·단위테스트가 모두 초록인데 납품 경로에서 동작하지 않는 결함이
셋 있었다.** 전부 "테스트가 기본값이 아닌 설정을 썼다"거나 "상류 허용 목록을 안 봤다"는
같은 모양이다.
### CRITICAL
- [x] **R4-1 [B]** 추락 승격이 **기본 설정에서 한 번도 발화하지 않았다.** `link_s`(3.0)를
`now` 기준으로 쟀는데 넘어짐은 `prone_confirm_s`(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 `prone_confirm_s=0.5` 를 써서 초록이었다.
DONE — 착지(prone 시작) 시각과 비교(`landed_from_drop`). **기본값 그대로 도는 회귀
테스트를 추가**했다(설정을 낮추지 말 것이라고 테스트에 적었다).
- [x] **R4-2 [B]** `max_workers: 0` 이 상류 허용 목록 두 곳에서 막혀 있었다 —
`ZoneConfigController::RULE_BOUNDS` 가 `[1,50]` 이라 저장이 400, `edge/zones.py` 가
`v <= 0` 으로 규칙 **전체**를 무효화(같이 넣은 2인1조까지 사라짐). 앞서 고친 네 곳은
전부 이 둘보다 하류라 도달 불가였다. DONE.
- [x] **R4-3 [B]** 고소작업 프리셋(`elevated`)이 **완전히 무동작**이었다. 서버 경계에 키가
없어 400, 통과하더라도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다 — 이 제품에서 가장 나쁜 종류의 결함이다. DONE.
- [x] **R4-4 [B]** 동시 다발이 **한 사람을 두 명으로 셀 수 있었다.** 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(`max_age=1.0`), 누운 사람은 새 트랙에서도 즉시 prone 이라
두 번째 `fall` 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가
**구조가 늦어진다** — 기능의 가치가 정반대로 뒤집힌다. DONE — `ghost` 를 인원 집계에서
빼고, 거의 같은 자리(0.08 미만)의 사건은 같은 사람으로 본다.
### HIGH
- [x] **R4-5 [H]** 구역 그룹핑이 사문화돼 있었다 — 엣지가 넘어짐·미동·소실에 `zone_id` 를
싣지 않아 카메라가 다른 두 희생자가 절대 묶이지 않았다(밀폐공간이 정확히 그 상황).
DONE — 사람 상태 이벤트에 구역을 싣고, 소실 이벤트는 마지막 구역을 물려준다.
- [x] **R4-6 [H]** 창이 미끄러지면 `group[0]` 이 바뀌어 uuid 가 달라져, 한 사고가 매 분
새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집었다. DONE — 이미 올린
무리는 인원이 늘 때만 다시 올린다.
- [x] **R4-7 [H]** 무장이 만료되지 않아 발판에 잠깐 올라갔던 사람이 몇 분 뒤 걸어 나가도
추락 경보가 됐다. `engine.forget()` 이 ghost 상태를 안 지워 트랙 교체 시 다른 사람이
무장을 물려받았다. 프레임 안에서 이미 낸 추락이 소실 경로에서 한 번 더 났다. DONE.
- [x] **R4-8 [H]** 업그레이드 안내에 마이그레이션 2개가 빠져 있었다. 그중 하나가 없으면
신규 유형 INSERT 가 DB CHECK 에 전부 걸린다 — 감지기는 도는데 화면에 아무것도 안 뜬다.
DONE + **README 가 실제 마이그레이션을 전부 담는지 검사하는 테스트** 추가.
- [x] **R4-9 [H]** `BUILD-INFO` 의 커밋 스탬프가 **1.1.0 에서 고친 결함(R3-6)의 재발**.
절차로 막으려다 두 번 실패했으므로 DONE — HEAD 와 일치하는지 검사하는 테스트로 막았다.
### MEDIUM
- [x] **R4-10 [M]** 정비·작업허가 억제가 "공기호흡기 없이 진입 금지"를 삼켰다. 허가가
발급된 그 구역·그 시간이 질식이 일어나는 자리다. DONE — 위급 3종은 억제 예외.
- [x] **R4-11 [M]** 미래 시각 이벤트가 무관한 넘어짐과 짝지어져 가스 경보를 만들 수 있었다
(폐쇄망은 NTP 부재가 흔하다). DONE.
- [x] **R4-12 [M]** 문자열 접두사 비교로 `cam-1` 과 `cam-10` 이 한 사건으로 합쳐졌다. DONE.
- [x] **R4-13 [M]** 좌표 없는 희생자와 좌표 있는 희생자가 영영 안 묶여 진짜 동시 다발을
놓쳤다. DONE — 좌표가 하나라도 없으면 쪼개지 않는다.
- [x] **R4-14 [M]** 데모 시더 심각도가 제품과 **7종** 어긋나 있었다. 특히 `fall` 이 위급으로
찍혀 1.2.0 의 요점(넘어짐 HIGH / 추락 CRITICAL)이 데모에서 지워져 있었다. 라벨과 달리
화면이 깨지지 않아 눈으로는 안 잡힌다. DONE + 엣지를 단일 출처로 강제하는 테스트.
- [x] **R4-15 [M]** 리포트의 사고 집계에서 추락이 빠져 니어미스:사고 비율이 가장 중대한
유형을 빼고 계산됐다. DONE.
- [x] **R4-16 [M]** `elevated` 가 저장 후 초기화되지 않아, 고치는 순간 이후의 모든 구역이
조용히 고소 구역이 될 상태였다(최상위 심각도 오탐 발생기). DONE — 저장·카메라 전환·
지우기에서 초기화하고 목록에 '고소'로 되읽기.
- [x] **R4-17 [M]** `GET /api/zone-presets` 만 인증 게이트가 없었다. DONE.
- [x] **R4-18 [M]** 문서의 "17종"이 실제와 달랐다(활성 20종 = 엣지 16 + 서버 4).
노조 대응 문장에도 쓰이는 숫자다. 소개서·덱·노조 문서·브리프 DONE, 신규 2종 행 추가.
- [x] **R4-19~23 [M/L]** 음수 입력 침묵 폐기, PROJECT_MAP 신규 파일 누락, 소개서 버전
표기, 마이그레이션 수, 프리셋 왕복 테스트 부재. 전부 DONE.
### 반증되어 채택하지 않은 것 (REFUTED)
- ghost 후보 튜플 확장이 어딘가에서 unpack 불일치를 낸다 → 생성·소비 모두 6-wide, 다른 소비자 없음.
- `rules` 가 None/비-dict 일 때 `in_elevated_zone` 이 예외 → `(z.rules or {})` 로 방어됨.
- 빈 입력란이 `max_workers: 0` 으로 저장된다 → `parseInt('')` 는 NaN, `NaN >= 0` 은 거짓.
- 구역 편집에서 기존 규칙이 사라진다 → 편집 경로 자체가 없다(삭제 후 재작성).
- 존 id 가 사이트 간 충돌한다 → 서버 생성 32자 난수라 전역 유일.
### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시)
- **`INFER_EVERY ≥ 2` 에서 추락 신호 약화** — 6fps 에서는 낙하 중 IoU 가 임계 아래로 떨어져
트랙이 끊길 수 있다. 티어2는 원래 반응 granularity 를 포기하는 모드이고, 실장비 벤치마크
없이 임계를 조정하면 감지 **누락**을 만든다. 커미셔닝에서 실영상으로 정한다.
- **프레임 하단에서 시작하는 추락** — bbox 가 화면 끝에서 잘려 하강량이 임계에 못 미칠 수
있다. 고소 구역을 그리면 잡힌다. 화각 설계로 푸는 문제라 코드로 막지 않는다.
- **`DescentConfig` 튜닝 불가** — 다른 임계값과 달리 env·서버 튜닝 경로가 없다. 실장비에서
기본값이 맞는지 먼저 보고 나서 노출한다(지금 노출하면 검증 안 된 노브가 는다).
- **매뉴얼에 신규 유형 설명 없음** — 3차 재감사의 잔여와 같은 항목. 다음 개정에서 일괄.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능) ## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다. 코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.

View file

@ -304,7 +304,7 @@
``` ```
[1] 감지 [2] 대응 [3] 증거 [1] 감지 [2] 대응 [3] 증거
───────────── ───────────────── ───────────────── ───────────── ───────────────── ─────────────────
17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
엣지 AI 판정 담당자 통보 (1분) 증거 3프레임 엣지 AI 판정 담당자 통보 (1분) 증거 3프레임
얼굴 자동 블러 상위 확대 (5분) 통보·조치 이력 얼굴 자동 블러 상위 확대 (5분) 통보·조치 이력
위·변조 탐지 기록 위·변조 탐지 기록

View file

@ -101,7 +101,7 @@
- **얼굴 블러** — 엣지에서, 업로드 전에 - **얼굴 블러** — 엣지에서, 업로드 전에
- **개인 식별자 없음** — 사람을 임시 추적번호로만 다루고, **누구인지 모릅니다.** - **개인 식별자 없음** — 사람을 임시 추적번호로만 다루고, **누구인지 모릅니다.**
사번·이름·얼굴 인식 어느 것도 없습니다 사번·이름·얼굴 인식 어느 것도 없습니다
- **근무태도 관련 이벤트 0종** — 감지 17종 전부가 *위험 상태* 이고, - **근무태도 관련 이벤트 0종** — 감지 20종 전부가 *위험 상태* 이고,
자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다** 자리 비움·작업 속도·휴게 시간 같은 항목은 **설계상 존재하지 않습니다**
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아 - **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
지우거나 고치면 검증에서 드러납니다 지우거나 고치면 검증에서 드러납니다

View file

@ -6,7 +6,7 @@
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지 > 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다. > 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
버전 1.1.0 · 2026-09-04 버전 1.2.0 · 2026-09-05
--- ---
@ -58,7 +58,7 @@
``` ```
감지 대응 증거 감지 대응 증거
───────── ───────────────── ──────────────────── ───────── ───────────────── ────────────────────
17종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지 20종 이벤트 → 현장 경광등 (즉시) → 사고조사 패키지
엣지 AI 판정 담당자 통보 증거 3프레임 엣지 AI 판정 담당자 통보 증거 3프레임
얼굴 블러 미확인 시 상위 확대 통보 이력 · 조치 이력 얼굴 블러 미확인 시 상위 확대 통보 이력 · 조치 이력
위·변조 탐지 감사기록 위·변조 탐지 감사기록
@ -69,7 +69,7 @@
### 2-1. 감지 — 무엇을 알아채는가 ### 2-1. 감지 — 무엇을 알아채는가
엣지가 **17종**을 판정하고, 여기에 서버가 발행하는 '미확인 경보 확대' 1종이 더해집니다. 엣지가 **16종**을 판정하고, 여기에 서버가 발행하는 4종(장치 이상 · 감시 성능 저하 · 미확인 경보 확대 · 동시 다발 쓰러짐)이 더해져 **모두 20종**입니다.
사람에 대한 것, 구역 규칙에 대한 것, 그리고 **감시 장비 자신**에 대한 것으로 나뉩니다. 사람에 대한 것, 구역 규칙에 대한 것, 그리고 **감시 장비 자신**에 대한 것으로 나뉩니다.
**사람의 위험 상태** **사람의 위험 상태**
@ -77,6 +77,8 @@
| 이벤트 | 무엇을 잡는가 | | 이벤트 | 무엇을 잡는가 |
|--------|---------------| |--------|---------------|
| 넘어짐 | 기립→쓰러짐 전이를 자세로 판정. 쓰러진 자세가 **3초**(조정 가능) 지속되면 확정 — 앉는 동작과 구분 | | 넘어짐 | 기립→쓰러짐 전이를 자세로 판정. 쓰러진 자세가 **3초**(조정 가능) 지속되면 확정 — 앉는 동작과 구분 |
| **추락 (떨어짐)** | 발 밑선이 평소 키의 1.2배 이상 급강하하면 추락으로 판정 — 같은 높이의 넘어짐과 **다른 사건**으로 분류합니다. 산업재해 사망 1위 유형이고, 척추 손상 우려가 있어 통보에 이송 주의가 함께 나갑니다 |
| **동시 다발 쓰러짐** | 같은 구역에서 짧은 시간에 2명 이상이 쓰러지면 질식·가스·폭발로 봅니다. 통보에 **"공기호흡기 없이 진입하지 마십시오"** 가 실립니다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람입니다 |
| **넘어짐 후 미동 없음** | 쓰러진 뒤 움직임이 없으면 **위급으로 승격** — 의식 소실 의심 | | **넘어짐 후 미동 없음** | 쓰러진 뒤 움직임이 없으면 **위급으로 승격** — 의식 소실 의심 |
| 장시간 부동 | 자세와 무관하게 오래 움직이지 않는 작업자 | | 장시간 부동 | 자세와 무관하게 오래 움직이지 않는 작업자 |
| 위험 상태 소실 | 위험한 상태였던 사람이 화면에서 사라짐 (가림·추락 등) | | 위험 상태 소실 | 위험한 상태였던 사람이 화면에서 사라짐 (가림·추락 등) |
@ -261,7 +263,7 @@
## 6. 납품과 운영 ## 6. 납품과 운영
- **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 8종 포함)를 zip 하나로 업로드, - **웹호스팅 업로드 패키지** — 서버 일체(마이그레이션 9종 포함)를 zip 하나로 업로드,
설정 파일 한 개만 수정 설정 파일 한 개만 수정
- **Pi 설치 번들** — 온라인 설치와 **폐쇄망 오프라인 번들** 모두 제공. - **Pi 설치 번들** — 온라인 설치와 **폐쇄망 오프라인 번들** 모두 제공.
런타임 휠·모델·소스를 전부 동봉해 설치 중 네트워크가 필요 없습니다 런타임 휠·모델·소스를 전부 동봉해 설치 중 네트워크가 필요 없습니다
@ -339,5 +341,5 @@
| **핵심 차별점** | ① 감지에서 끝나지 않고 **현장 경광등·에스컬레이션까지** ② **감시 자신의 고장**을 감지 ③ **사고조사 패키지 1클릭** ④ 위·변조 탐지 감사기록 | | **핵심 차별점** | ① 감지에서 끝나지 않고 **현장 경광등·에스컬레이션까지** ② **감시 자신의 고장**을 감지 ③ **사고조사 패키지 1클릭** ④ 위·변조 탐지 감사기록 |
| **프라이버시** | 라이브뷰·영상 전송 없음 · 얼굴 저장 전 블러 · 알림에 이미지 미포함 | | **프라이버시** | 라이브뷰·영상 전송 없음 · 얼굴 저장 전 블러 · 알림에 이미지 미포함 |
| **운영 비용** | 클라우드 GPU 없음, 일반 웹호스팅에서 동작 | | **운영 비용** | 클라우드 GPU 없음, 일반 웹호스팅에서 동작 |
| **감지 유형** | 17종 활성 (+ 모델 확보 대기 3종) | | **감지 유형** | 20종 활성 (+ 모델 확보 대기 3종) |
| **검증** | 테스트 535건 · 납품 감사 55건 전량 해소 | | **검증** | 테스트 535건 · 납품 감사 55건 전량 해소 |

View file

@ -8,6 +8,9 @@ _DANGEROUS = {"FALLING", "FALLEN", "NO_MOTION"}
@dataclass @dataclass
class GhostConfig: class GhostConfig:
reappear_s: float = 10.0 reappear_s: float = 10.0
# 높이 무장의 유효 기간. 무장에 시간 제한이 없으면 발판에 잠깐 올라갔던 사람이 몇 분 뒤
# 그냥 걸어 나가도 최상위 추락 경보가 된다.
height_arm_ttl_s: float = 20.0
reappear_dist: float = 1.0 # bbox 높이 배수 reappear_dist: float = 1.0 # bbox 높이 배수
@ -25,9 +28,21 @@ class GhostIncidentDetector:
def note_state(self, track_id: int, state: str) -> None: def note_state(self, track_id: int, state: str) -> None:
self._states[track_id] = state self._states[track_id] = state
def arm_height(self, track_id: int, descent: dict | None, elevated: bool) -> None: def forget(self, track_id: int) -> None:
"""트랙 상태를 버린다. 엔진이 트랙 교체(ambiguous)에서도 부른다 — 무장이 남으면
교차한 뒤 살아남은 다른 사람이 그 무장을 물려받아 추락 경보를 낸다."""
self._states.pop(track_id, None)
self._height.pop(track_id, None)
def disarm_height(self, track_id: int) -> None:
"""프레임 안에서 이미 추락으로 발보했으면 소실 경로가 같은 사고를 또 내지 않게 한다."""
self._height.pop(track_id, None)
def arm_height(self, track_id: int, descent: dict | None, elevated: bool,
now: float) -> None:
"""이 트랙을 추락 후보로 표시한다. 소실되면 fall_from_height 로 발보한다.""" """이 트랙을 추락 후보로 표시한다. 소실되면 fall_from_height 로 발보한다."""
info = self._height.setdefault(track_id, {}) info = self._height.setdefault(track_id, {})
info["armed_at"] = now
if descent: if descent:
info.update(descent) info.update(descent)
info["armed_by"] = "descent" info["armed_by"] = "descent"
@ -40,6 +55,11 @@ class GhostIncidentDetector:
for tr in lost_tracks: for tr in lost_tracks:
state = self._states.get(tr.track_id, "STANDING") state = self._states.get(tr.track_id, "STANDING")
height = self._height.get(tr.track_id) height = self._height.get(tr.track_id)
# 오래된 무장은 무시한다 (H2). 고소 구역 안에 있는 동안은 매 틱 갱신되므로
# 구역 기반 무장은 이 검사에 걸리지 않는다.
if height is not None and now - float(height.get("armed_at", now)) > self.cfg.height_arm_ttl_s:
height = None
self._height.pop(tr.track_id, None)
if state in _DANGEROUS or height: if state in _DANGEROUS or height:
b = tr.latest.bbox b = tr.latest.bbox
# Snapshot the OTHER tracks active at loss time. Only a track that did NOT # Snapshot the OTHER tracks active at loss time. Only a track that did NOT

View file

@ -39,8 +39,15 @@ class DescentConfig:
drop_window_s: float = 1.2 drop_window_s: float = 1.2
# 구간 동안 사람이 이 배수 이상 커졌으면 카메라 쪽으로 걸어온 것이다. # 구간 동안 사람이 이 배수 이상 커졌으면 카메라 쪽으로 걸어온 것이다.
grow_guard: float = 1.35 grow_guard: float = 1.35
# 하강 후 이 시간 안에 넘어짐이 확정되면 같은 사건으로 묶어 유형을 승격한다. # 하강과 착지를 같은 사건으로 묶는 허용 오차.
#
# 이 값을 "하강 후 now 까지"로 재면 안 된다. 넘어짐은 prone 이 prone_confirm_s(기본 3초)
# 지속돼야 확정되므로, 확정 시점에는 하강으로부터 항상 3초 넘게 지나 있다 — 기본값끼리
# 부딪혀 승격이 영원히 일어나지 않았다. 착지(prone 시작) 시각과 비교해야 한다.
link_s: float = 3.0 link_s: float = 3.0
# 무장 유효 기간. 하강 한 번이 트랙의 남은 평생을 무장 상태로 두면, 몇 분 뒤 그냥
# 걸어 나간 사람이 최상위 추락 경보가 된다.
arm_ttl_s: float = 20.0
class DescentWatch: class DescentWatch:
@ -51,10 +58,30 @@ class DescentWatch:
def forget(self, track_id: int) -> None: def forget(self, track_id: int) -> None:
self._seen.pop(track_id, None) self._seen.pop(track_id, None)
def descending(self, track_id: int, now: float) -> bool: def armed_at(self, track_id: int) -> float | None:
"""이 트랙이 최근 link_s 안에 떨어졌는가.""" """이 트랙의 하강이 확정된 시각. 없으면 None."""
hit = self._seen.get(track_id) hit = self._seen.get(track_id)
return hit is not None and (now - hit[0]) <= self.cfg.link_s return hit[0] if hit is not None else None
def landed_from_drop(self, track_id: int, prone_since: float | None) -> bool:
"""이 트랙의 prone 시작이 하강 직후인가 — 즉 떨어져서 누운 것인가.
now 가 아니라 **착지 시각**과 비교하는 것이 핵심이다. 확정은 prone_confirm_s 만큼
늦게 오므로 now 로 재면 기본값에서 영원히 거짓이 된다.
"""
hit = self._seen.get(track_id)
if hit is None or prone_since is None:
return False
return abs(prone_since - hit[0]) <= self.cfg.link_s
def armed(self, track_id: int, now: float) -> bool:
"""소실 시 추락으로 볼 만큼 최근에 떨어졌는가."""
hit = self._seen.get(track_id)
return hit is not None and (now - hit[0]) <= self.cfg.arm_ttl_s
def descending(self, track_id: int, now: float) -> bool:
"""하위 호환용 별칭. 새 코드는 landed_from_drop / armed 를 쓴다."""
return self.armed(track_id, now)
def detail(self, track_id: int) -> dict | None: def detail(self, track_id: int) -> dict | None:
hit = self._seen.get(track_id) hit = self._seen.get(track_id)
@ -65,7 +92,7 @@ class DescentWatch:
frames = list(track.frames) frames = list(track.frames)
if len(frames) < 2: if len(frames) < 2:
return None return None
if self.descending(track.track_id, now): if self.armed(track.track_id, now):
return None # 이미 무장돼 있다 — 같은 추락을 매 틱 다시 확정하지 않는다 return None # 이미 무장돼 있다 — 같은 추락을 매 틱 다시 확정하지 않는다
last = frames[-1] last = frames[-1]

View file

@ -38,6 +38,9 @@ class EventEngine:
self.descent = descent if descent is not None else DescentWatch(DescentConfig()) self.descent = descent if descent is not None else DescentWatch(DescentConfig())
self.cfg = cfg self.cfg = cfg
self._last_emit: dict[tuple, float] = {} self._last_emit: dict[tuple, float] = {}
# 트랙별 마지막 구역. 소실 이벤트(ghost/추락)는 소실 후에 발보되므로
# 그때는 트랙이 없다 — 마지막으로 있던 구역을 들고 있어야 실을 수 있다.
self._last_zone: dict[int, str | None] = {}
def _allow(self, key: tuple, now: float) -> bool: def _allow(self, key: tuple, now: float) -> bool:
last = self._last_emit.get(key) last = self._last_emit.get(key)
@ -52,6 +55,8 @@ class EventEngine:
self.immobility.forget(track_id) self.immobility.forget(track_id)
self.zone.forget(track_id) self.zone.forget(track_id)
self.descent.forget(track_id) self.descent.forget(track_id)
self.ghost.forget(track_id)
self._last_zone.pop(track_id, None)
self.occupancy.forget(track_id) self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items() self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)} if not (len(k) >= 2 and k[1] == track_id)}
@ -79,7 +84,14 @@ class EventEngine:
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone # frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size. # space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh) zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id)) active_zones = self.zone.active_zone_ids(tr.track_id)
in_zone = bool(active_zones)
# 사람 상태 이벤트(넘어짐·미동·소실)에도 구역을 싣는다. 서버의 동시 다발 판정이
# "같은 구역"으로 묶으려면 이 값이 있어야 하고, 없으면 카메라가 다른 두 희생자가
# 절대 묶이지 않는다 — 밀폐공간이 정확히 그 상황이다.
# 여러 구역이 겹치면 정렬해 첫 번째를 쓴다(같은 입력에 같은 답이 나오도록).
here = sorted(active_zones)[0] if active_zones else None
self._last_zone[tr.track_id] = here
# 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미 # 발 좌표(0..1)를 트랙 이벤트 전부에 부착 — 서버 히트맵의 원료. 존 감지기가 이미
# 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다. # 계산하는 좌표계를 그대로 재사용해 존 폴리곤과 같은 공간에 놓인다.
foot = self.zone._foot(tr, frame_wh) foot = self.zone._foot(tr, frame_wh)
@ -90,7 +102,7 @@ class EventEngine:
if descent is not None or elevated: if descent is not None or elevated:
# 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로 # 위험 자세가 아니어도 ghost 를 무장시킨다. 발판에 서 있다가 화각 밖으로
# 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다. # 떨어지는 사람은 소실 순간 STANDING 이라, 기존 무장 조건으로는 안 걸린다.
self.ghost.arm_height(tr.track_id, descent, elevated) self.ghost.arm_height(tr.track_id, descent, elevated, now)
sig, meta = self.fall.update(tr, now) sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
@ -102,7 +114,10 @@ class EventEngine:
if sig == "fall": if sig == "fall":
# 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다. # 방금 떨어진 사람이 바닥에서 확정되면 그것은 넘어짐이 아니라 추락이다.
# 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다. # 두 건으로 올리지 않고 유형만 올린다 — 한 번의 사고는 한 건이다.
falling = self.descent.descending(tr.track_id, now) # 착지(prone 시작) 시각과 하강을 비교한다. now 로 비교하면 확정이
# prone_confirm_s 만큼 늦게 오므로 기본값에서 영원히 거짓이 된다.
prone_since = state.prone_since if state is not None else None
falling = self.descent.landed_from_drop(tr.track_id, prone_since)
etype = EventType.FALL_FROM_HEIGHT if (falling or elevated) else EventType.FALL etype = EventType.FALL_FROM_HEIGHT if (falling or elevated) else EventType.FALL
if self._allow((etype, tr.track_id), now): if self._allow((etype, tr.track_id), now):
m = dict(meta) m = dict(meta)
@ -112,10 +127,13 @@ class EventEngine:
if elevated: if elevated:
m["elevated_zone"] = True m["elevated_zone"] = True
events.append(self._mk(etype, occurred_at, tr.track_id, events.append(self._mk(etype, occurred_at, tr.track_id,
metrics=m, spatial=foot)) metrics=m, spatial=foot, zone_id=here))
if etype is EventType.FALL_FROM_HEIGHT:
# 프레임 안에서 이미 냈으므로 소실 경로가 같은 사고를 또 내지 않게 한다.
self.ghost.disarm_height(tr.track_id)
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now): elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id,
metrics=meta, spatial=foot)) metrics=meta, spatial=foot, zone_id=here))
elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now): elif sig == "near_fall" and self._allow((EventType.NEAR_FALL, tr.track_id), now):
# 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료. # 니어미스(FALLING까지 갔다 확정 전 회복): 알람용이 아니라 히트맵/통계 재료.
events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id, events.append(self._mk(EventType.NEAR_FALL, occurred_at, tr.track_id,
@ -185,6 +203,7 @@ class EventEngine:
else: else:
etype = EventType.GHOST etype = EventType.GHOST
events.append(self._mk(etype, occurred_at, track_id=g["track_id"], events.append(self._mk(etype, occurred_at, track_id=g["track_id"],
zone_id=self._last_zone.get(g["track_id"]),
spatial=gs, metrics=gmeta)) spatial=gs, metrics=gmeta))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe. # ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.

View file

@ -17,7 +17,16 @@ def _looks_like_pixels(polygon: list[tuple[float, float]]) -> bool:
return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon) return any(abs(x) > _NORMALIZED_MAX or abs(y) > _NORMALIZED_MAX for x, y in polygon)
_RULE_KEYS = ("min_workers", "max_workers", "max_dwell_s") _RULE_INT_KEYS = ("min_workers", "max_workers", "max_dwell_s")
# 키마다 하한이 다르다. max_workers 만 0 이 유효하다 — "아무도 있으면 안 되는 구역"
# (크레인으로 든 짐 아래, 굴착기 선회 반경). 전 키에 v <= 0 을 걸면 그 규칙을 저장한 구역이
# 통째로 '규칙 없음'이 되고, 같이 넣은 2인1조까지 함께 사라진다.
_RULE_MIN = {"min_workers": 1, "max_workers": 0, "max_dwell_s": 1}
# 정수가 아니라 참/거짓인 규칙. 이 목록에 없으면 파서가 조용히 버린다 — elevated 를 여기
# 넣지 않아 고소작업 프리셋이 통째로 무동작이었다.
_RULE_FLAG_KEYS = ("elevated",)
def _rules_of(row: dict) -> dict | None: def _rules_of(row: dict) -> dict | None:
@ -31,13 +40,18 @@ def _rules_of(row: dict) -> dict | None:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError(f"rules_json is not an object: {data!r}") raise ValueError(f"rules_json is not an object: {data!r}")
rules = {} rules = {}
for key in _RULE_KEYS: for key in _RULE_INT_KEYS:
if data.get(key) is None: if data.get(key) is None:
continue continue
v = int(data[key]) v = int(data[key])
if v <= 0: lo = _RULE_MIN[key]
raise ValueError(f"{key} must be positive, got {v}") if v < lo:
raise ValueError(f"{key} must be >= {lo}, got {v}")
rules[key] = v rules[key] = v
for key in _RULE_FLAG_KEYS:
if data.get(key) is None:
continue
rules[key] = bool(data[key])
return rules or None return rules or None

View file

@ -94,6 +94,8 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
| 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 | | 5 | `2026-08-06-false-positive-tag.sql` | 오탐 태그 컬럼 |
| 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) | | 6 | `2026-08-07-improvement-actions.sql` | 개선 조치 표(`move_actions`) |
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 | | 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`) 유형 허용 |
1. phpMyAdmin → 해당 DB → **SQL** 탭 1. phpMyAdmin → 해당 DB → **SQL** 탭
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다 2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다

View file

@ -236,6 +236,26 @@
// 프리셋이 elevated 를 켜면 화면에 입력란이 없으므로 여기 들고 있는다(고소 구역 표시). // 프리셋이 elevated 를 켜면 화면에 입력란이 없으므로 여기 들고 있는다(고소 구역 표시).
var elevated = false; var elevated = false;
function resetPreset() {
elevated = false;
var sel = document.getElementById('zone-preset');
if (sel) sel.value = '';
var help = document.getElementById('zone-preset-help');
if (help) { help.hidden = true; help.textContent = ''; }
}
/** 입력값이 음수면 규칙을 조용히 버리지 않고 이유를 알린다. */
function invalidRuleInput() {
var pairs = [[ruleMinInput, '최소 인원'], [ruleMaxInput, '최대 인원'], [ruleDwellInput, '체류 상한']];
for (var i = 0; i < pairs.length; i++) {
var el = pairs[i][0];
if (!el || el.value === '') continue;
var n = parseInt(el.value, 10);
if (isNaN(n) || n < 0) return pairs[i][1] + ' 값이 올바르지 않습니다: ' + el.value;
}
return null;
}
function rulesFromInputs() { function rulesFromInputs() {
var rules = {}; var rules = {};
var minW = parseInt(ruleMinInput && ruleMinInput.value, 10); var minW = parseInt(ruleMinInput && ruleMinInput.value, 10);
@ -255,6 +275,7 @@
try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; } try { r = JSON.parse(z.rules_json || 'null') || {}; } catch (e) { return ''; }
var parts = []; var parts = [];
if (r.min_workers) parts.push('최소 ' + r.min_workers + '인'); if (r.min_workers) parts.push('최소 ' + r.min_workers + '인');
if (r.elevated) parts.push('고소');
if (r.max_workers === 0) parts.push('출입 금지'); if (r.max_workers === 0) parts.push('출입 금지');
else if (r.max_workers) parts.push('최대 ' + r.max_workers + '인'); else if (r.max_workers) parts.push('최대 ' + r.max_workers + '인');
if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분'); if (r.max_dwell_s) parts.push('체류 ' + Math.round(r.max_dwell_s / 60) + '분');
@ -479,7 +500,7 @@
return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json }; return { zone_id: z.zone_id, level: z.level, points: parsePolygon(z), rules_json: z.rules_json };
}); });
currentPoints = []; currentPoints = [];
renderZoneList(); // 프리셋은 '지금 그리는 구역'에만 적용된다. 저장 후 그대로 두면 다음 폴리곤이
redraw(); redraw();
setStatus(doneMsg || (existingZones.length setStatus(doneMsg || (existingZones.length
? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.' ? '저장된 구역 ' + existingZones.length + '개를 불러왔습니다.'
@ -493,6 +514,10 @@
var device = deviceInput.value.trim(); var device = deviceInput.value.trim();
if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; } if (!camera) { setStatus('카메라를 먼저 선택하세요.'); return; }
if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; } if (currentPoints.length < 3) { setStatus('폴리곤 점을 3개 이상 클릭하세요.'); return; }
// 음수·비정상 입력을 조용히 버리면 운영자는 규칙이 저장된 줄 안다. 이 변경 전체가
// 없애려던 침묵이 바로 그것이므로 여기서 이유를 말하고 멈춘다.
var bad = invalidRuleInput();
if (bad) { setStatus(bad); return; }
setStatus('저장 중...'); setStatus('저장 중...');
fetch(B + '/api/zones', { fetch(B + '/api/zones', {
method: 'POST', method: 'POST',
@ -517,6 +542,11 @@
.then(function (data) { .then(function (data) {
if (!data) return; if (!data) return;
currentPoints = []; currentPoints = [];
// 프리셋은 '지금 그리는 구역'에만 적용된다. 저장 후 그대로 두면 다음 폴리곤이
// 보이지 않는 상태를 물려받는다 — elevated 는 화면에 표시가 없어 운영자가
// 켜져 있는지 알 수 없고, 평범한 바닥 구역이 전부 고소 구역이 되면 트랙이
// 사라질 때마다 최상위 심각도 추락 경보가 뜬다.
resetPreset();
loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.'); loadZones('구역을 저장했습니다. 다음 설정 동기화 때 장치로 내려갑니다.');
}) })
.catch(function () { setStatus('저장에 실패했습니다.'); }); .catch(function () { setStatus('저장에 실패했습니다.'); });
@ -550,7 +580,12 @@
if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); }); if (frameBtn) frameBtn.addEventListener('click', function () { applySelection(); loadLatestFrame(); });
if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); }); if (heatmapBtn) heatmapBtn.addEventListener('click', function () { applySelection(); toggleHeatmap(); });
if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); }); if (heatmapDaysInput) heatmapDaysInput.addEventListener('change', function () { if (heatmapOn) loadHeatmap(); });
clearBtn.addEventListener('click', function () { currentPoints = []; redraw(); setStatus('그리던 폴리곤을 지웠습니다.'); }); clearBtn.addEventListener('click', function () {
currentPoints = [];
resetPreset();
redraw();
setStatus('그리던 폴리곤과 용도 선택을 지웠습니다.');
});
saveBtn.addEventListener('click', function () { applySelection(); saveZone(); }); saveBtn.addEventListener('click', function () { applySelection(); saveZone(); });
if (deviceSelect) { if (deviceSelect) {
@ -568,6 +603,7 @@
cameraInput.value = cameraSelect.value; cameraInput.value = cameraSelect.value;
if (!cameraSelect.value) return; if (!cameraSelect.value) return;
currentPoints = []; currentPoints = [];
resetPreset();
loadZones(); loadZones();
loadLatestFrame(); loadLatestFrame();
}); });

View file

@ -132,7 +132,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('GET', '/api/zone-presets', fn(Request $q) => \App\Http\Response::json(['presets' => \App\Api\ZonePresetCatalog::all()])); $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));
$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q)); $r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q));

View file

@ -36,6 +36,12 @@ final class AlertDispatcher
/** Ranked so a channel's min_severity acts as a floor, not an equality test. */ /** Ranked so a channel's min_severity acts as a floor, not an equality test. */
private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3]; private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3];
/**
* 정비·작업허가 억제로도 끌 수 없는 유형.
* 억제는 예상된 알람을 끄는 장치이지, 사람이 쓰러진 사실을 감추는 장치가 아니다.
*/
private const NEVER_SUPPRESSED = ['multi_down', 'no_motion', 'fall_from_height'];
public function __construct( public function __construct(
private EventRepository $events, private EventRepository $events,
private AlertRepository $alerts, private AlertRepository $alerts,
@ -61,6 +67,13 @@ final class AlertDispatcher
$scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]]; $scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]];
if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']]; if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']];
$suppressed = $this->suppressions->activeFor($scopes, $now) !== []; $suppressed = $this->suppressions->activeFor($scopes, $now) !== [];
// 억제는 '지금 여기서 작업 중이라 예상된 알람'을 끄기 위한 것이다. 그런데 밀폐공간
// 작업허가가 발급된 바로 그 구역·그 시간이 질식이 일어나는 자리이므로, 허가가
// 동시 다발 쓰러짐의 "공기호흡기 없이 진입 금지" 경고를 삼키면 그 기능이 존재하는
// 유일한 상황에서 침묵한다. 사람이 실제로 쓰러진 위급은 억제 대상이 아니다.
if ($suppressed && in_array($event['event_type'] ?? '', self::NEVER_SUPPRESSED, true)) {
$suppressed = false;
}
$out = []; $out = [];
foreach ($channels as $ch) { foreach ($channels as $ch) {

View file

@ -29,8 +29,16 @@ use App\Repository\EventRepository;
*/ */
final class MultiDownWatch final class MultiDownWatch
{ {
/** 사람이 쓰러졌다는 뜻의 유형. ergo/near_fall 처럼 통계용은 제외한다. */ /**
private const DOWN_TYPES = ['fall', 'fall_from_height', 'no_motion', 'ghost']; * 사람이 쓰러졌다는 뜻의 유형.
*
* ghost(위험 상태 소실)는 **일부러 뺐다.** 그것은 이미 쓰러진 사람의 후속 사건이라
* 같은 희생자를 한 명 더로 세게 만든다. 트래커가 가려짐으로 트랙을 놓쳤다가 새 id 로
* 다시 잡으면 fall→ghost→fall 이 한 사람에게서 나오는데, 그것이 "2명 쓰러짐"으로
* 읽히면 다친 한 사람에게 "공기호흡기 없이 진입하지 마십시오"를 보내 **구조를 늦춘다.**
* 이 기능의 가치가 정확히 반대로 뒤집히는 경우라 소음보다 훨씬 나쁘다.
*/
private const DOWN_TYPES = ['fall', 'fall_from_height', 'no_motion'];
public function __construct(private EventRepository $events) {} public function __construct(private EventRepository $events) {}
@ -51,9 +59,27 @@ final class MultiDownWatch
$minPeople = max(2, (int)($opts['min_people'] ?? 2)); $minPeople = max(2, (int)($opts['min_people'] ?? 2));
$radius = max(0.0, (float)($opts['radius'] ?? 0.35)); $radius = max(0.0, (float)($opts['radius'] ?? 0.35));
$limit = max(1, (int)($opts['limit'] ?? 500)); $limit = max(1, (int)($opts['limit'] ?? 500));
// 이 거리보다 가까운 두 사건은 같은 사람으로 본다 (트래커 재획득 방어).
$minSeparation = max(0.0, (float)($opts['min_separation'] ?? 0.08));
$rows = []; $rows = [];
$alreadyReported = []; // scope -> 이미 올린 최대 인원
foreach ($this->events->forNotification((float)($now - $window), $limit) as $ev) { foreach ($this->events->forNotification((float)($now - $window), $limit) as $ev) {
// 미래 시각 배제. 폐쇄망은 NTP 가 없는 경우가 흔해 장치 시계가 앞서 나간다
// (스키마가 received_at 을 두는 이유가 그것이다). 앞선 시각의 사건 하나가
// 몇 분 동안 창에 남아 무관한 넘어짐과 짝지어져 가스 경보를 만든다.
if ((float)($ev['occurred_at'] ?? 0) > (float)$now) continue;
if (($ev['event_type'] ?? '') === 'multi_down') {
// 이미 올린 무리는 다시 올리지 않는다. 창이 미끄러지면 group[0] 이 바뀌어
// uuid 가 달라지므로, uuid 만으로는 같은 사고가 매 분 새 건으로 쏟아진다.
$sc = (string)($ev['metrics']['scope'] ?? '');
$pp = (int)($ev['metrics']['people'] ?? 0);
if ($sc !== '' && $pp > (int)($alreadyReported[$sc] ?? 0)) {
$alreadyReported[$sc] = $pp;
}
continue;
}
if (!in_array($ev['event_type'] ?? '', self::DOWN_TYPES, true)) continue; if (!in_array($ev['event_type'] ?? '', self::DOWN_TYPES, true)) continue;
$rows[] = $ev; $rows[] = $ev;
} }
@ -61,12 +87,11 @@ final class MultiDownWatch
$filed = []; $filed = [];
foreach ($this->groups($rows, $radius) as $scopeKey => $group) { foreach ($this->groups($rows, $radius) as $scopeKey => $group) {
// 같은 사람이 여러 번 쓰러진 것은 한 명이다. $people = $this->distinctPeople($group, $minSeparation);
$people = [];
foreach ($group as $ev) {
$people[$this->personKey($ev)] = true;
}
if (count($people) < $minPeople) continue; if (count($people) < $minPeople) continue;
// 인원이 늘었을 때만 다시 올린다. 같거나 줄었으면 이미 알린 사고다 —
// 줄어든 수를 다시 보내면 "3명"이라 했다가 "2명"이라고 정정하는 꼴이 된다.
if (count($people) <= (int)($alreadyReported[(string)$scopeKey] ?? 0)) continue;
$first = $group[0]; $first = $group[0];
$uuid = self::uuidFor((string)$scopeKey, (int)$first['event_id'], count($people)); $uuid = self::uuidFor((string)$scopeKey, (int)$first['event_id'], count($people));
@ -118,6 +143,44 @@ final class MultiDownWatch
. ($ev['track_id'] ?? ('e' . ($ev['event_id'] ?? ''))); . ($ev['track_id'] ?? ('e' . ($ev['event_id'] ?? '')));
} }
/**
* 서로 다른 사람 몇 명인가.
*
* track_id 만으로 세면 안 된다. 트래커는 1초만 가려져도 트랙을 버리고 새 id 를 준다
* (edge/tracking.py max_age=1.0). 바닥에 누운 사람은 새 트랙에서도 즉시 prone 이라
* 같은 사람이 두 번째 fall 을 만든다. 그래서 **거의 같은 자리**의 사건은 같은 사람으로
* 본다 — 두 사람이 물리적으로 같은 점에 쓰러질 수는 없다.
*
* @return array<int,array> 사람마다 대표 이벤트 하나
*/
private function distinctPeople(array $group, float $minSeparation): array
{
$people = []; // personKey -> 대표 이벤트
foreach ($group as $ev) {
$key = $this->personKey($ev);
if (isset($people[$key])) continue;
$merged = false;
foreach ($people as $seen) {
if ($this->sameSpot($ev, $seen, $minSeparation)) { $merged = true; break; }
}
if (!$merged) $people[$key] = $ev;
}
return $people;
}
/** 두 사건이 같은 사람으로 볼 만큼 붙어 있는가. 좌표가 없으면 판단하지 않는다. */
private function sameSpot(array $a, array $b, float $minSeparation): bool
{
if ($minSeparation <= 0.0) return false;
if ($a['spatial_x'] === null || $a['spatial_y'] === null
|| $b['spatial_x'] === null || $b['spatial_y'] === null) {
return false;
}
$dx = (float)$a['spatial_x'] - (float)$b['spatial_x'];
$dy = (float)$a['spatial_y'] - (float)$b['spatial_y'];
return sqrt($dx * $dx + $dy * $dy) < $minSeparation;
}
/** /**
* 공간적으로 같은 자리인 것끼리 묶는다. 구역이 있으면 구역이 우선 — 운영자가 "여기가 * 공간적으로 같은 자리인 것끼리 묶는다. 구역이 있으면 구역이 우선 — 운영자가 "여기가
* 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다. * 한 공간"이라고 선언한 것이 좌표 거리보다 정확하다.
@ -126,36 +189,56 @@ final class MultiDownWatch
*/ */
private function groups(array $rows, float $radius): array private function groups(array $rows, float $radius): array
{ {
$out = []; // 1단계: 장소로 나눈다. 구역이 있으면 구역이 우선 — 운영자가 "여기가 한 공간"이라고
// 선언한 것이 좌표 거리보다 정확하고, 카메라를 넘어서도 묶인다.
// 키는 구분자를 넣어 정확 비교한다. 문자열 접두사로 비교하면 cam-1 과 cam-10 이,
// 충전실과 충전실2 가 한 사건으로 합쳐진다.
$byPlace = [];
foreach ($rows as $ev) { foreach ($rows as $ev) {
$zone = $ev['zone_id'] ?? null; $zone = $ev['zone_id'] ?? null;
if ($zone !== null && $zone !== '') { $key = ($zone !== null && $zone !== '')
$out['zone:' . $zone][] = $ev; ? 'zone:' . $zone
: 'camera:' . ($ev['device_id'] ?? '') . "" . ($ev['camera_id'] ?? '');
$byPlace[$key][] = $ev;
}
// 2단계: 카메라 묶음만 좌표로 다시 쪼갠다. 같은 카메라라도 화면 반대편은 다른 사건이다.
// 좌표가 없는 사건이 하나라도 있으면 쪼개지 않는다 — 쪼개면 좌표 있는 희생자와
// 좌표 없는 희생자가 영영 만나지 못해 진짜 동시 다발을 놓친다.
$out = [];
foreach ($byPlace as $key => $bucket) {
if (str_starts_with((string)$key, 'zone:')) {
$out[$key] = $bucket;
continue; continue;
} }
$key = 'camera:' . ($ev['device_id'] ?? '') . '/' . ($ev['camera_id'] ?? ''); $anyMissing = false;
$placed = false; foreach ($bucket as $ev) {
// 좌표가 둘 다 있으면 거리로 다시 쪼갠다 — 같은 카메라라도 반대편 끝은 다른 사건이다. if ($ev['spatial_x'] === null || $ev['spatial_y'] === null) { $anyMissing = true; break; }
if ($ev['spatial_x'] !== null && $ev['spatial_y'] !== null) { }
foreach ($out as $k => $bucket) { if ($anyMissing || $radius <= 0.0) {
if (!str_starts_with((string)$k, $key)) continue; $out[$key] = $bucket;
$head = $bucket[0]; continue;
if ($head['spatial_x'] === null || $head['spatial_y'] === null) continue; }
$clusters = []; // 대표점 => 사건들
foreach ($bucket as $ev) {
$placed = false;
foreach ($clusters as $ci => $c) {
$head = $c[0];
$dx = (float)$ev['spatial_x'] - (float)$head['spatial_x']; $dx = (float)$ev['spatial_x'] - (float)$head['spatial_x'];
$dy = (float)$ev['spatial_y'] - (float)$head['spatial_y']; $dy = (float)$ev['spatial_y'] - (float)$head['spatial_y'];
if (sqrt($dx * $dx + $dy * $dy) <= $radius) { if (sqrt($dx * $dx + $dy * $dy) <= $radius) {
$out[$k][] = $ev; $clusters[$ci][] = $ev;
$placed = true; $placed = true;
break; break;
} }
} }
if (!$placed) { if (!$placed) $clusters[] = [$ev];
$out[$key . '@' . round((float)$ev['spatial_x'], 2) }
. ',' . round((float)$ev['spatial_y'], 2)][] = $ev; foreach ($clusters as $ci => $c) {
} // 군집이 하나면 좌표를 키에 넣지 않는다 — 넣으면 창이 미끄러져 대표점이
continue; // 바뀔 때마다 scope 가 달라져 같은 사고가 새 건으로 올라간다.
$out[count($clusters) === 1 ? $key : $key . '#' . $ci] = $c;
} }
$out[$key][] = $ev;
} }
return $out; return $out;
} }

View file

@ -55,7 +55,10 @@ final class ReportController
if (isset($severityTotals[$row['severity']])) $severityTotals[$row['severity']] += $row['count']; if (isset($severityTotals[$row['severity']])) $severityTotals[$row['severity']] += $row['count'];
$typeCounts[$row['event_type']] = ($typeCounts[$row['event_type']] ?? 0) + $row['count']; $typeCounts[$row['event_type']] = ($typeCounts[$row['event_type']] ?? 0) + $row['count'];
if ($row['event_type'] === 'near_fall') $nearFall += $row['count']; if ($row['event_type'] === 'near_fall') $nearFall += $row['count'];
if (in_array($row['event_type'], ['fall', 'no_motion'], true)) $falls += $row['count']; // 추락을 빼면 니어미스:사고 비율이 가장 중대한 유형을 빼고 계산된다.
if (in_array($row['event_type'], ['fall', 'fall_from_height', 'no_motion'], true)) {
$falls += $row['count'];
}
if (in_array($row['event_type'], self::OCCUPANCY_TYPES, true)) { if (in_array($row['event_type'], self::OCCUPANCY_TYPES, true)) {
$occupancy[$row['event_type']] += $row['count']; $occupancy[$row['event_type']] += $row['count'];
} }

View file

@ -15,10 +15,16 @@ final class ZoneConfigController
// by an occupied zone — meaningless); dwell floor 10s rejects a seconds/minutes mixup. // by an occupied zone — meaningless); dwell floor 10s rejects a seconds/minutes mixup.
private const RULE_BOUNDS = [ private const RULE_BOUNDS = [
'min_workers' => [2, 50], 'min_workers' => [2, 50],
'max_workers' => [1, 50], // 하한이 0 인 것은 오타가 아니다. 0 = "아무도 있으면 안 되는 구역"(크레인으로 든 짐
// 아래, 굴착기 선회 반경 — 깔림 사고의 전형)이고, 그 규칙을 표현할 다른 방법이 없다.
// 이 경계가 1 이던 동안 감지기·화면·직렬화를 아무리 고쳐도 저장 자체가 400 이었다.
'max_workers' => [0, 50],
'max_dwell_s' => [10, 86400], 'max_dwell_s' => [10, 86400],
]; ];
/** 정수 범위가 아니라 참/거짓인 규칙. 값이 참일 때만 저장한다. */
private const RULE_FLAGS = ['elevated'];
public function __construct( public function __construct(
private ZoneRepository $zones, private DeviceRepository $devices, private ZoneRepository $zones, private DeviceRepository $devices,
private AuditRepository $audit, private SessionStore $sess, private AuditRepository $audit, private SessionStore $sess,
@ -42,6 +48,20 @@ final class ZoneConfigController
return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId)); return SiteScope::allows(is_string($scope) ? $scope : null, $this->devices->siteFor($deviceId));
} }
/**
* GET /api/zone-presets — 용도별 구역 프리셋 목록.
*
* 인라인 클로저로 두었더니 이 앱에서 유일하게 인증 게이트가 없는 대시보드 API 가 됐다.
* 내용은 정적 한국어 안내문이라 유출 자체의 피해는 없지만, 공유호스팅에 인증 없는 JSON
* 종단점을 하나 열어 두는 것은 다음 사람이 같은 모양으로 진짜 데이터를 내보내게 만든다.
* 다른 라우트와 같은 자리, 같은 게이트로 옮긴다.
*/
public function presets(Request $req): Response
{
if ($g = $this->guard('view_events')) return $g;
return Response::json(['presets' => \App\Api\ZonePresetCatalog::all()], 200);
}
public function list(Request $req, array $params): Response public function list(Request $req, array $params): Response
{ {
// A role gate (not just "authenticated"): without it any logged-in account could enumerate // A role gate (not just "authenticated"): without it any logged-in account could enumerate
@ -82,6 +102,12 @@ final class ZoneConfigController
if (!is_array($rules)) return [null, false]; if (!is_array($rules)) return [null, false];
$out = []; $out = [];
foreach ($rules as $key => $value) { foreach ($rules as $key => $value) {
if (in_array($key, self::RULE_FLAGS, true)) {
// elevated: 고소 구역 표시. 추락 판정의 보강 신호이므로 참일 때만 남긴다.
if (!is_bool($value)) return [null, false];
if ($value) $out[$key] = true;
continue;
}
if (!isset(self::RULE_BOUNDS[$key])) return [null, false]; if (!isset(self::RULE_BOUNDS[$key])) return [null, false];
if ($value === null || $value === '') continue; if ($value === null || $value === '') continue;
if (!is_int($value) && !(is_string($value) && ctype_digit($value))) return [null, false]; if (!is_int($value) && !(is_string($value) && ctype_digit($value))) return [null, false];
@ -90,7 +116,9 @@ final class ZoneConfigController
if ($n < $lo || $n > $hi) return [null, false]; if ($n < $lo || $n > $hi) return [null, false];
$out[$key] = $n; $out[$key] = $n;
} }
if (isset($out['min_workers'], $out['max_workers']) && $out['min_workers'] > $out['max_workers']) { if (isset($out['min_workers'], $out['max_workers'])
&& is_int($out['min_workers']) && is_int($out['max_workers'])
&& $out['min_workers'] > $out['max_workers']) {
return [null, false]; return [null, false];
} }
return [$out === [] ? null : json_encode($out), true]; return [$out === [] ? null : json_encode($out), true];

View file

@ -31,8 +31,8 @@ final class MultiDownWatchTest extends TestCase
public function testTwoPeopleDownInTheSameSpotFilesOneCriticalEvent(): void public function testTwoPeopleDownInTheSameSpotFilesOneCriticalEvent(): void
{ {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.50, 'spatial_y' => 0.50]); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40, 'spatial_y' => 0.50]);
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.55, 'spatial_y' => 0.52, $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'spatial_y' => 0.55,
'occurred_at' => (float)(self::NOW - 30)]); 'occurred_at' => (float)(self::NOW - 30)]);
$filed = (new MultiDownWatch($repo))->run(self::NOW); $filed = (new MultiDownWatch($repo))->run(self::NOW);
@ -91,8 +91,8 @@ final class MultiDownWatchTest extends TestCase
public function testRepeatedRunsDoNotDuplicate(): void public function testRepeatedRunsDoNotDuplicate(): void
{ {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1]); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40]);
$this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.58, 'occurred_at' => (float)(self::NOW - 30)]);
$w = new MultiDownWatch($repo); $w = new MultiDownWatch($repo);
self::assertCount(1, $w->run(self::NOW)); self::assertCount(1, $w->run(self::NOW));
@ -104,17 +104,99 @@ final class MultiDownWatchTest extends TestCase
{ {
// 사람이 늘어난 것은 상황이 악화된 것이다 — 한 번 더 올라와야 한다. // 사람이 늘어난 것은 상황이 악화된 것이다 — 한 번 더 올라와야 한다.
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1]); $this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30]);
$this->ev($repo, ['track_id' => 2, 'occurred_at' => (float)(self::NOW - 30)]); $this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'occurred_at' => (float)(self::NOW - 30)]);
$w = new MultiDownWatch($repo); $w = new MultiDownWatch($repo);
self::assertCount(1, $w->run(self::NOW)); self::assertCount(1, $w->run(self::NOW));
$this->ev($repo, ['track_id' => 3, 'occurred_at' => (float)(self::NOW - 10)]); $this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 10)]);
$again = $w->run(self::NOW + 5); $again = $w->run(self::NOW + 5);
self::assertCount(1, $again); self::assertCount(1, $again);
self::assertSame(3, $again[0]['people']); self::assertSame(3, $again[0]['people']);
} }
// --- 한 사람을 여러 명으로 세지 않는다 ---------------------------------------------
//
// 이 묶음이 이 클래스에서 가장 중요하다. 잘못 세면 다친 한 사람에게
// "공기호흡기 없이 진입하지 마십시오"를 보내 **구조를 늦춘다** — 기능의 가치가
// 정반대로 뒤집히는 경우라 단순한 오탐보다 훨씬 나쁘다.
public function testTrackerReAcquisitionOfOneVictimIsNotTwoPeople(): void
{
// 트래커는 1초만 가려져도 트랙을 버리고 새 id 를 준다(edge/tracking.py max_age=1.0).
// 바닥에 누운 사람은 새 트랙에서도 즉시 prone 이라 같은 사람이 두 번째 fall 을 만든다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40, 'spatial_y' => 0.60]);
$this->ev($repo, ['track_id' => 12, 'spatial_x' => 0.41, 'spatial_y' => 0.60,
'occurred_at' => (float)(self::NOW - 20)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW),
'같은 자리의 재획득을 두 명으로 세면 구조가 늦어진다');
}
public function testGhostIsNotCountedAsAPerson(): void
{
// ghost 는 이미 쓰러진 사람의 후속 사건이다. 세면 한 희생자가 두 명이 된다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 7, 'spatial_x' => 0.40]);
$this->ev($repo, ['track_id' => 7, 'event_type' => 'ghost', 'spatial_x' => 0.75,
'occurred_at' => (float)(self::NOW - 20)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW));
}
public function testFutureTimestampedEventCannotManufactureAGroup(): void
{
// 폐쇄망은 NTP 가 없는 경우가 흔해 장치 시계가 앞서 나간다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.40,
'occurred_at' => (float)(self::NOW + 600)]); // 시계 10분 빠름
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.60,
'occurred_at' => (float)(self::NOW - 10)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW));
}
public function testPrefixCameraIdsAreNotMerged(): void
{
// 문자열 접두사로 묶으면 cam-1 과 cam-10 이 한 사건이 된다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'camera_id' => 'cam-10', 'spatial_x' => 0.50]);
$this->ev($repo, ['track_id' => 2, 'camera_id' => 'cam-1', 'spatial_x' => 0.52,
'occurred_at' => (float)(self::NOW - 30)]);
self::assertSame([], (new MultiDownWatch($repo))->run(self::NOW));
}
public function testVictimWithoutCoordinatesStillGroups(): void
{
// 좌표가 없는 사건(ghost 경로 등)이 섞였다고 진짜 동시 다발을 놓치면 안 된다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => null, 'spatial_y' => null]);
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'spatial_y' => 0.50,
'occurred_at' => (float)(self::NOW - 20)]);
self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW));
}
public function testSlidingWindowDoesNotRefileTheSameIncident(): void
{
// 창이 미끄러지면 group[0] 이 바뀌어 uuid 가 달라진다. 그것만으로는 같은 사고가
// 매 분 새 위급 건으로 쏟아지고, 인원이 줄면 앞선 알림을 스스로 뒤집는다.
$repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'spatial_x' => 0.30, 'occurred_at' => (float)(self::NOW - 100)]);
$this->ev($repo, ['track_id' => 2, 'spatial_x' => 0.50, 'occurred_at' => (float)(self::NOW - 60)]);
$this->ev($repo, ['track_id' => 3, 'spatial_x' => 0.62, 'occurred_at' => (float)(self::NOW - 20)]);
$w = new MultiDownWatch($repo);
$total = 0;
for ($t = 0; $t <= 600; $t += 60) { // 크론 10분치
$total += count($w->run(self::NOW + $t));
}
self::assertLessThanOrEqual(2, $total, "한 사고가 $total 건으로 올라갔다");
self::assertGreaterThanOrEqual(1, $total);
}
public function testNonDownTypesAreIgnored(): void public function testNonDownTypesAreIgnored(): void
{ {
// 니어미스·자세부담은 사람이 쓰러진 것이 아니다. // 니어미스·자세부담은 사람이 쓰러진 것이 아니다.
@ -129,8 +211,8 @@ final class MultiDownWatchTest extends TestCase
public function testFallFromHeightCountsAsDown(): void public function testFallFromHeightCountsAsDown(): void
{ {
$repo = new InMemoryEventRepository(); $repo = new InMemoryEventRepository();
$this->ev($repo, ['track_id' => 1, 'event_type' => 'fall_from_height']); $this->ev($repo, ['track_id' => 1, 'event_type' => 'fall_from_height', 'spatial_x' => 0.35]);
$this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion', $this->ev($repo, ['track_id' => 2, 'event_type' => 'no_motion', 'spatial_x' => 0.60,
'occurred_at' => (float)(self::NOW - 30)]); 'occurred_at' => (float)(self::NOW - 30)]);
self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW)); self::assertCount(1, (new MultiDownWatch($repo))->run(self::NOW));

View file

@ -115,10 +115,18 @@ $profile = [
'cam-paint-01' => ['weight' => 1, 'types' => ['monitoring_degraded', 'device_fault', 'zone_warning']], 'cam-paint-01' => ['weight' => 1, 'types' => ['monitoring_degraded', 'device_fault', 'zone_warning']],
]; ];
$severityOf = [ $severityOf = [
'fall' => 'critical', 'no_motion' => 'critical', 'pinch' => 'critical', // 심각도는 edge/events.py 의 _SEVERITY 가 단일 출처다. 여기서 다른 값을 쓰면
'immobility' => 'high', 'ghost' => 'high', 'zone_restricted' => 'high', 'lone_worker' => 'high', // 데모·스크린샷·UI 검토가 실제 제품과 다른 등급을 보여준다 — 특히 1.2.0 의
'near_fall' => 'warning', 'zone_warning' => 'warning', 'overcapacity' => 'warning', 'overstay' => 'warning', // 요점인 넘어짐(HIGH) 대 추락(CRITICAL) 구분이 데모에서 지워진다.
'ergo_report' => 'info', 'monitoring_degraded' => 'info', 'device_fault' => 'info', // tests/test_seed_demo_severity.py 가 두 표의 일치를 강제한다.
'camera_moved' => 'high', 'camera_obstructed' => 'high', 'device_fault' => 'warning',
'ergo_report' => 'info', 'fall' => 'high', 'fall_from_height' => 'critical',
'fire_smoke' => 'critical', 'ghost' => 'critical', 'immobility' => 'warning',
'lone_worker' => 'high', 'low_light' => 'warning', 'monitoring_degraded' => 'warning',
'multi_down' => 'critical', 'near_fall' => 'info', 'no_motion' => 'critical',
'overcapacity' => 'warning', 'overstay' => 'warning', 'pinch' => 'high',
'ppe_missing' => 'warning', 'proximity' => 'high', 'unacked_escalation' => 'critical',
'zone_restricted' => 'high', 'zone_warning' => 'warning',
]; ];
$pdo->beginTransaction(); $pdo->beginTransaction();

View file

@ -76,3 +76,48 @@ def test_pi_bundle_records_its_provenance():
for field in ("version:", "git_commit:", "built_at:"): for field in ("version:", "git_commit:", "built_at:"):
assert field in text, f"MANIFEST.txt에 {field} 없음" assert field in text, f"MANIFEST.txt에 {field} 없음"
assert "version: unknown" not in text assert "version: unknown" not in text
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_build_info_stamps_the_commit_it_actually_ships():
"""BUILD-INFO 의 git_commit 이 현재 HEAD 와 같은가.
1.1.0 에서 이 결함(R3-6)을 고쳤는데 1.2.0 에서 그대로 재발했다 — 릴리스 커밋 **전에**
패키지를 만들면 스탬프가 코드를 담지 않은 커밋을 가리킨다. 기존 테스트는 필드의 존재만
확인했으므로 두 번 다 통과했다.
스탬프가 틀리면 나중에 "고객이 받은 것이 어느 코드인지"를 되짚을 방법이 사라진다.
절차로 막으려다 두 번 실패했으므로 테스트로 막는다.
"""
import subprocess
info = (CAFE24_PRIV / "BUILD-INFO.txt").read_text(encoding="utf-8")
stamped = next((l.split(":", 1)[1].strip() for l in info.splitlines()
if l.startswith("git_commit:")), None)
assert stamped, f"BUILD-INFO 에 git_commit 이 없습니다:\n{info}"
if stamped in ("unknown", ""):
pytest.skip("git 정보 없이 빌드된 패키지")
head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
capture_output=True, text=True).stdout.strip()
assert stamped == head, (
f"패키지가 담은 코드와 스탬프가 다릅니다: BUILD-INFO={stamped}, HEAD={head}. "
"릴리스 커밋 이후에 생성기를 다시 돌리십시오.")
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_upload_readme_lists_every_migration():
"""업그레이드 안내의 마이그레이션 표가 실제 파일을 전부 담는가.
빠지면 업그레이드한 고객이 그 마이그레이션을 영영 실행하지 않는다. 1.2.0 직전 상태에서
두 개가 빠져 있었고, 그중 하나가 없으면 신규 이벤트 유형의 INSERT 가 DB CHECK 에 걸려
전부 거부된다 — 감지기는 도는데 대시보드에 아무것도 안 뜨는, 이 프로젝트가 반복해서
잡아온 조용한 실패다.
"""
readme = (ROOT / "server" / "deploy" / "README-UPLOAD.md").read_text(encoding="utf-8")
for sql in sorted((ROOT / "server" / "migrations").glob("*.sql")):
assert sql.name in readme, (
f"업그레이드 안내에 {sql.name} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 "
"영영 실행하지 않습니다")

View file

@ -411,3 +411,72 @@ def test_elevated_zone_arms_without_a_visible_drop():
fh = [e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT] fh = [e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT]
assert len(fh) == 1, [e.event_type for e in out] assert len(fh) == 1, [e.event_type for e in out]
assert fh[0].metrics["armed_by"] == "zone" assert fh[0].metrics["armed_by"] == "zone"
def test_height_fall_upgrade_works_at_PRODUCTION_defaults():
"""기본 설정 그대로 — 이 테스트가 없어서 승격이 죽어 있는 채로 커밋됐다.
첫 구현은 하강을 `now` 와 비교했다. 넘어짐은 prone_confirm_s(기본 3초) 지속 후에 확정되므로
확정 시점에는 하강으로부터 항상 3초 넘게 지나 있었고, link_s 도 3.0 이라 승격이 **한 번도**
일어나지 않았다. 다른 테스트들이 prone_confirm_s=0.5 를 써서 전부 초록이었다.
그래서 이 테스트는 감지기 설정을 건드리지 않는다. 기본값끼리 부딪히는 것이 요점이다.
"""
eng = EventEngine(
FallDetector(FallConfig()), # ← 기본값. 절대 낮추지 말 것.
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
descent=DescentWatch(DescentConfig()), # ← 기본값
)
tr = Track(77, deque(maxlen=90))
# 발판(y2=200)에서 바닥(y2=410)으로 1.17초 낙하 — 키 150px 대비 1.4배
for t, y2 in [(0.0, 200), (0.25, 250), (0.5, 310), (0.83, 370), (1.17, 410)]:
_falling_track(tr, t, y2=float(y2))
eng.process([tr], [], now=t)
# 착지 후 12fps 로 계속 누워 있음
t = 1.17
out = []
while t < 8.0 and not out:
t = round(t + 1 / 12, 3)
_lying_track(tr, t)
out = eng.process([tr], [], now=t)
types = [e.event_type for e in out]
assert EventType.FALL_FROM_HEIGHT in types, f"기본 설정에서 승격 실패: {types}"
assert EventType.FALL not in types
def test_one_height_fall_does_not_also_fire_on_track_loss():
# 프레임 안에서 추락으로 냈는데 그 사람의 검출이 끊기면, 소실 경로가 같은 사고를 또 낸다.
eng = _height_engine()
tr = Track(88, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0)
_falling_track(tr, 0.5, y2=330.0)
_falling_track(tr, 0.9, y2=430.0)
eng.process([tr], [], now=0.9)
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0)
_lying_track(tr, 1.6)
first = eng.process([tr], [], now=1.6)
assert EventType.FALL_FROM_HEIGHT in [e.event_type for e in first]
eng.process([], [tr], now=2.0) # 검출 끊김
later = [e.event_type for e in eng.process([], [], now=5.0)]
# 추락은 한 번만. ghost(위험 상태 소실)는 별개 사건이라 그대로 둔다 — 쓰러진 사람이
# 화면에서 사라진 것은 옮겨졌을 수 있다는 뜻이고, 1.0.0 부터의 동작이다.
assert EventType.FALL_FROM_HEIGHT not in later, f"같은 사고가 두 번 올라갔다: {later}"
assert later == [EventType.GHOST], later
def test_forget_clears_height_arming_so_it_does_not_ride_to_another_person():
# 두 사람이 교차해 트랙이 뒤바뀌면 엔진이 forget 을 부른다. 무장이 남으면 살아남은
# 다른 사람이 나중에 화면 밖으로 걸어 나갈 때 추락 경보가 된다.
eng = _height_engine()
tr = Track(99, deque(maxlen=90))
_falling_track(tr, 0.0, y2=200.0)
_falling_track(tr, 0.9, y2=430.0)
eng.process([tr], [], now=0.9)
eng.forget(99)
eng.process([], [tr], now=1.0)
assert eng.process([], [], now=5.0) == []

View file

@ -51,12 +51,29 @@ def test_rapid_drop_of_the_foot_line_is_a_descent():
assert w.descending(1, now=0.9) is True assert w.descending(1, now=0.9) is True
def test_descent_stays_armed_for_the_link_window(): def test_landing_is_linked_to_the_drop_not_to_now():
"""확정 시각이 아니라 **착지 시각**과 비교해야 한다.
넘어짐은 prone 이 prone_confirm_s(기본 3초) 지속돼야 확정되므로, 확정 시점에는 하강으로부터
항상 3초 넘게 지나 있다. now 로 재던 첫 구현은 기본값끼리 부딪혀 승격이 영원히 일어나지
않았다 — 감지기도 테스트도 통과하는데 현장에서만 안 되는 종류였다.
"""
w = _watch(link_s=3.0) w = _watch(link_s=3.0)
tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))]) tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))])
w.update(tr, now=0.9) w.update(tr, now=0.9)
assert w.descending(1, now=3.5) is True # 아직 연결 창 안 # 착지는 하강 직후 — now 가 한참 뒤여도 승격되어야 한다
assert w.descending(1, now=4.5) is False # 창을 벗어남 assert w.landed_from_drop(1, prone_since=1.0) is True
assert w.landed_from_drop(1, prone_since=30.0) is False # 한참 뒤에 누운 것은 별건
assert w.landed_from_drop(1, prone_since=None) is False
def test_arming_expires_so_a_walker_is_not_a_height_fall():
# 하강 한 번이 트랙의 평생을 무장 상태로 두면, 몇 분 뒤 걸어 나간 사람이 최상위 경보가 된다.
w = _watch(arm_ttl_s=20.0)
tr = _track(1, [(0.0, _box(0.30, 0.25)), (0.9, _box(0.65, 0.25))])
w.update(tr, now=0.9)
assert w.armed(1, now=10.0) is True
assert w.armed(1, now=25.0) is False
# --- 잡으면 안 되는 것 ---------------------------------------------------------------- # --- 잡으면 안 되는 것 ----------------------------------------------------------------

View file

@ -0,0 +1,56 @@
"""데모 시더의 심각도가 실제 제품과 같은가.
시더는 데모·스크린샷·UI 검토에 쓰는 데이터를 만든다. 여기 심각도가 엣지와 다르면 검토자는
제품이 실제로 내지 않는 등급을 보고 판단하게 된다. 실제로 7종이 어긋나 있었고, 그중 `fall`
이 위급으로 찍혀 1.2.0 의 요점(넘어짐 HIGH / 추락 CRITICAL)이 데모에서 지워져 있었다.
라벨과 달리 심각도는 어긋나도 화면이 깨지지 않는다 — 그래서 사람 눈으로는 잡히지 않고
이 테스트가 필요하다.
"""
import re
from pathlib import Path
from edge.events import EventType, Severity, _SEVERITY
ROOT = Path(__file__).resolve().parents[1]
SEEDER = ROOT / "server" / "tools" / "seed_demo.php"
# 서버만 발행하는 유형 — 엣지 enum 에 없다.
SERVER_ONLY = {"unacked_escalation": "critical", "multi_down": "critical"}
def _seeder_map() -> dict[str, str]:
src = SEEDER.read_text(encoding="utf-8")
m = re.search(r"\$severityOf\s*=\s*\[(.*?)\];", src, re.S)
assert m, "seed_demo.php 의 $severityOf 배열을 찾지 못했습니다 (리팩터되었나요?)"
return dict(re.findall(r"'([a-z_]+)'\s*=>\s*'([a-z]+)'", m.group(1)))
def _expected() -> dict[str, str]:
out = {e.value: _SEVERITY.get(e, Severity.INFO).value for e in EventType}
out.update(SERVER_ONLY)
return out
def test_seeder_severity_matches_the_edge():
seeder, expected = _seeder_map(), _expected()
wrong = {k: (v, expected[k]) for k, v in seeder.items()
if k in expected and v != expected[k]}
assert not wrong, (
"데모 시더가 제품과 다른 심각도를 씁니다 (유형: 시더 -> 제품): "
+ ", ".join(f"{k}: {a} -> {b}" for k, (a, b) in sorted(wrong.items())))
def test_seeder_covers_every_type():
seeder, expected = _seeder_map(), _expected()
missing = set(expected) - set(seeder)
assert not missing, (
f"시더에 심각도가 없는 유형: {sorted(missing)} — "
"빠지면 기본값으로 조용히 잘못된 등급이 찍힙니다")
def test_seeder_has_no_unknown_types():
seeder, expected = _seeder_map(), _expected()
extra = set(seeder) - set(expected)
assert not extra, f"시더에만 있는 유형(오타이거나 삭제된 유형): {sorted(extra)}"

View file

@ -0,0 +1,84 @@
"""프리셋이 저장 경로를 온전히 통과하는가 — 서버 검증기 → DB → 엣지 파서.
WHY THIS TEST EXISTS
1.2.0 에서 프리셋 6종을 넣으면서 두 개가 **완전히 무동작**이었다:
· 출입 금지(max_workers: 0) — ZoneConfigController 의 규칙 경계가 [1,50] 이라 저장이 400
· 고소작업(elevated: true) — 서버 경계에 키가 없어 400, 설령 통과해도 엣지 파서가 침묵 삭제
감지기·입력란·직렬화·표시를 다 고쳤는데도 그랬다. 고친 네 곳이 전부 **허용 목록보다
하류**였기 때문이다 — 값이 존재해도 되는지 결정하는 층은 손대지 않았다.
단위 테스트는 Zone 을 직접 만들어 검사했으므로 이 구멍을 볼 수 없었다. 그래서 카탈로그가
선언한 규칙을 **실제 저장 경로 그대로** 흘려보내는 이 테스트가 필요하다.
프리셋이 약속한 것을 시스템이 하지 않는 것은 이 제품에서 가장 나쁜 종류의 결함이다.
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
from edge.zones import _rules_of
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "server" / "src" / "Api" / "ZonePresetCatalog.php"
def _presets() -> list[dict]:
"""PHP 카탈로그를 그대로 읽어 온다 — 파이썬에 값을 다시 적으면 그 사본이 또 어긋난다."""
out = subprocess.run(
["php", "-r",
'require "server/vendor/autoload.php";'
'echo json_encode(App\Api\ZonePresetCatalog::all());'],
cwd=ROOT, capture_output=True, text=True, timeout=60,
)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def _server_accepts(rules: dict) -> tuple[str | None, bool]:
"""ZoneConfigController::rulesJson 을 그대로 호출한다 (저장 경로의 1차 관문)."""
php = (
'require "server/vendor/autoload.php";'
'$m = new ReflectionMethod("App\\Dashboard\\ZoneConfigController", "rulesJson");'
'$m->setAccessible(true);'
'echo json_encode($m->invoke(null, json_decode($argv[1], true)));'
)
out = subprocess.run(["php", "-r", php, "--", json.dumps(rules)],
cwd=ROOT, capture_output=True, text=True, timeout=60)
assert out.returncode == 0, out.stderr
stored, ok = json.loads(out.stdout)
return stored, ok
php_missing = subprocess.run(["php", "-v"], capture_output=True).returncode != 0
pytestmark = pytest.mark.skipif(php_missing, reason="php not available")
@pytest.mark.parametrize("preset", _presets() if not php_missing else [],
ids=lambda p: p["id"])
def test_preset_survives_server_validation_and_edge_parsing(preset):
rules = preset["rules"]
stored, ok = _server_accepts(rules)
assert ok, f"서버가 프리셋 '{preset['id']}' 의 규칙을 거부했습니다: {rules}"
assert stored is not None, f"프리셋 '{preset['id']}' 이 규칙 없이 저장됩니다"
parsed = _rules_of({"rules_json": stored})
assert parsed is not None, f"엣지가 프리셋 '{preset['id']}' 의 규칙을 통째로 버렸습니다"
for key, want in rules.items():
assert key in parsed, (
f"프리셋 '{preset['id']}' 의 '{key}' 가 엣지까지 오지 못했습니다 — "
f"프리셋이 약속한 것을 시스템이 하지 않습니다. 저장본: {stored}, 파싱: {parsed}")
assert parsed[key] == want, f"{key}: {parsed[key]!r} != {want!r}"
def test_catalog_and_ui_agree_on_the_capacity_floor():
# 입력란 하한이 1 이면 운영자가 0 을 넣을 방법이 없다 — 화면도 허용 목록의 일부다.
view = (ROOT / "server" / "src" / "View" / "zones.php").read_text(encoding="utf-8")
m = re.search(r'id="rule-max-workers"[^>]*min="(\d+)"', view)
assert m, "최대 인원 입력란을 찾지 못했습니다"
assert m.group(1) == "0", "출입 금지 구역을 입력할 수 없습니다 (min 이 0 이어야 합니다)"