Compare commits
No commits in common. "e625d482ba7ecc0a40bf183e9ec0ded6e14fc338" and "5c7c65351d4f4da55c6ff26e8d7f47b5f83a028e" have entirely different histories.
e625d482ba
...
5c7c65351d
26 changed files with 39 additions and 636 deletions
|
|
@ -7,7 +7,7 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보
|
||||||
|
|
||||||
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
|
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
|
||||||
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
|
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
|
||||||
- 감사 상태: `docs/delivery-audit.md` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여).
|
- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여).
|
||||||
|
|
||||||
## 검증 (verify)
|
## 검증 (verify)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# INDEX — 작업 기록 (완료 결과만)
|
||||||
|
|
||||||
- **2026-08-18** 납품 재감사 — `7daee0b`. 확장된 표면(라우트 17·표 6종·CLI 도구 5·엣지 모듈 4·
|
|
||||||
통보 발송·감사 해시체인)을 8개 관점으로 재감사, 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 ·
|
|
||||||
MEDIUM 4 · LOW 2). 최대 발견은 **신규 감지 유형이 ingest 검증기에만 빠져 카메라 무결성
|
|
||||||
감지가 end-to-end 무력화**되어 있던 것 — DB CHECK·프런트·엣지는 다 갱신됐는데 가운데
|
|
||||||
계층만 빠졌고, 파이썬·PHP 테스트가 각자 절반만 보기 때문에 양쪽 다 통과했다.
|
|
||||||
재발 방지로 언어 경계를 넘는 파리티 테스트 신설(`tests/test_event_type_parity.py`).
|
|
||||||
그 외 운영 DB 파괴 가능한 `--purge` 범위, 백업이 docroot 자체에 떨어지던 경로 가드,
|
|
||||||
통보 메일 시각이 콘솔과 9시간 어긋나던 타임존, MariaDB 에서 실패하는 `DROP CHECK`.
|
|
||||||
판정: **적합** (`docs/delivery-audit.md` Phase 6). 잔여는 실장비 OFF-HW 5건.
|
|
||||||
검증: PHPUnit 224 (skip 0) · pytest 통과 · 양 산출물 재생성 + 패리티 통과.
|
|
||||||
|
|
||||||
- **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영.
|
- **2026-08-09** 안전 플랫폼 확장 — `e00ad69`. 로드맵 23건 중 소프트웨어로 완결 가능한 19건 반영.
|
||||||
마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
|
마이그레이션 `2026-08-09-safety-platform.sql` (표 6종 신설 + 컬럼 4종 + event_type CHECK 확장).
|
||||||
**통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.
|
**통보(제품 정의 수준의 구멍)**: 지금까지 감지는 브라우저를 보고 있는 사람에게만 전달됐다.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||||||
|
|
||||||
> **판정 (2026-08-18): 적합 — 오프하드웨어 항목 전량 해소.**
|
|
||||||
> 1차 감사 43/43 + 2차 재감사(안전 플랫폼 확장 표면) 12/12 해소.
|
|
||||||
> 잔여는 실장비(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
|
||||||
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
verification of every blocker against real code. Ordered for sequential fixing. Each item is
|
||||||
checked off only after a test locks it and verification passes.
|
checked off only after a test locks it and verification passes.
|
||||||
|
|
@ -262,109 +258,6 @@ misconfig/degradation fail LOUD.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6 — 재감사 2026-08-18 (안전 플랫폼 확장 표면)
|
|
||||||
|
|
||||||
커밋 `e00ad69` 로 감사 표면이 크게 늘었다: 신규 라우트 17개, DB 표 6종 + 컬럼 4종, CLI 도구
|
|
||||||
5개, 엣지 모듈 4개(GPIO 출력 포함), 통보 발송 경로(메일·웹훅) 신설, 감사로그 해시체인.
|
|
||||||
8개 관점(보안·데이터 정합·렌더·패키징·문서 정합·성능·UX·회귀)으로 재감사, 확정 12건 전량 해소.
|
|
||||||
|
|
||||||
### CRITICAL
|
|
||||||
|
|
||||||
- [x] **A1 [B]** `server/src/Ingest/EventValidator.php` — 신규 감지 유형(`camera_moved`,
|
|
||||||
`camera_obstructed`, `low_light`)이 ingest 허용 목록에 없어 **서버가 전부 400 으로 거부**했다.
|
|
||||||
`edge/queue.py` 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
|
|
||||||
전부 끝난 상태로 end-to-end 무력화되어 있었다 — 로그를 뒤지지 않는 한 아무도 모른다.
|
|
||||||
DB CHECK 와 프런트 라벨은 갱신했는데 **가운데 검증 계층만** 빠진, 계층 간 목록 중복의 전형적 결함.
|
|
||||||
DONE — 허용 목록 갱신 + **언어 경계를 넘는 회귀 테스트** 신설
|
|
||||||
(`tests/test_event_type_parity.py`: `edge/events.py` ↔ EventValidator ↔ schema.sql ↔
|
|
||||||
프런트 라벨 5종 ↔ 유형 필터). 파이썬·PHP 어느 쪽 테스트도 잡을 수 없던 자리다.
|
|
||||||
- [x] **A2 [B]** `server/tools/seed_demo.php` — `--purge` 가 위험성평가를
|
|
||||||
`reviewed_by='안전관리자' AND site='factory-a'` 로 지웠다. '안전관리자'는 이 제품의 **기본
|
|
||||||
관리자 계정명**이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제
|
|
||||||
위험성평가가 복구 불가능하게 사라진다.
|
|
||||||
DONE — 데모 행은 `DEMO_PREFIX`/`DEMO_ACTOR`/`DEMO_LABEL` 표식으로만 식별. 표식 없는 행은
|
|
||||||
어떤 경우에도 건드리지 않는다.
|
|
||||||
- [x] **A3 [B]** `server/tools/backup.php` — 웹 공개 경로 차단이 `str_contains($real, '/www/')`
|
|
||||||
라 **docroot 자체**(`/home/acct/www`)는 뒤에 슬래시가 없어 통과했다. 가장 흔한 오사용이
|
|
||||||
정확히 그 경우다. 전 현장의 사고 기록 + 작업자 이미지가 웹에서 내려받힌다 (파일명은
|
|
||||||
`move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만 알면 추측 가능).
|
|
||||||
DONE — 경로를 세그먼트로 쪼개 `www/public_html/htdocs/html/web/public` 중 하나라도 있으면 거부.
|
|
||||||
회귀 검증: docroot 자체·하위 경로 모두 차단, 비공개 경로는 통과.
|
|
||||||
|
|
||||||
### HIGH
|
|
||||||
|
|
||||||
- [x] **A4 [H]** 신규 유형이 `app.js`/`detail.js`/`report.js` 의 `TYPE_LABELS` 와 이벤트 유형
|
|
||||||
필터에 없어 화면에 `camera_moved` 같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만
|
|
||||||
걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다.
|
|
||||||
- [x] **A5 [H]** 서버가 `date_default_timezone_set` 를 하지 않아 통보 메일의 시각이 PHP 기본
|
|
||||||
(공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로
|
|
||||||
보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다.
|
|
||||||
DONE — `Config::timezone()` (기본 `Asia/Seoul`, 잘못된 값이면 폴백하되 발송은 막지 않음),
|
|
||||||
`Channels` 가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더.
|
|
||||||
- [x] **A6 [H]** 마이그레이션의 `ALTER TABLE … DROP CHECK` 는 MySQL 8.0.16+ 전용 문법이다.
|
|
||||||
카페24 공유호스팅에 흔한 **MariaDB 는 `DROP CONSTRAINT` 를 쓰므로 마이그레이션이 통째로 실패**한다.
|
|
||||||
DONE — 해당 블록에 엔진별 분기(A: MySQL 8.0.16+ / B: MariaDB / C: 제약 없음) 를 명시하고,
|
|
||||||
이 CHECK 가 2차 방어선이며 1차는 `EventValidator` 임을 적어 실패해도 판단할 수 있게 함.
|
|
||||||
적용 확인용 probe INSERT/DELETE 도 함께 기재.
|
|
||||||
|
|
||||||
### MEDIUM
|
|
||||||
|
|
||||||
- [x] **A7 [M]** `AlertDispatcher::run` 이 매분 창 전체를 다시 훑으며 이미 보낸
|
|
||||||
(event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라
|
|
||||||
'창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다.
|
|
||||||
DONE — `claimedSince()` 로 창의 원장을 한 번에 읽어 사전 필터. `claim()` 은 여전히 최종
|
|
||||||
권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정.
|
|
||||||
- [x] **A8 [M]** `move_alert_log` 에 보존 정책이 없어 유일하게 무한 증가하는 표였다
|
|
||||||
(이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다).
|
|
||||||
DONE — `maintain.php` 가 해소된 전달(sent/suppressed)만 정리. **failed 는 남긴다** —
|
|
||||||
"아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다.
|
|
||||||
- [x] **A9 [M]** 전역 관리자가 현장 채널을 수정하면 `site` 가 NULL 로 덮여 그 채널이 **모든
|
|
||||||
현장에 발화**하게 됐다. 폼 어디에도 그런 안내가 없다.
|
|
||||||
DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가.
|
|
||||||
- [x] **A10 [M]** 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
|
|
||||||
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
|
|
||||||
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
|
|
||||||
`성공/전체` 를 함께 표기.
|
|
||||||
|
|
||||||
### LOW
|
|
||||||
|
|
||||||
- [x] **A11 [L]** 패키지 테스트가 `tools/` 배치를 검사하지 않았다. 이 도구들은 메일을 보내고
|
|
||||||
DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다.
|
|
||||||
DONE — ① 모든 CLI 도구가 `bootstrap.php`(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가
|
|
||||||
private 폴더에만 있는지 ③ 웹 루트의 PHP 가 `index.php`/`_preflight.php` 뿐인지 검사.
|
|
||||||
- [x] **A12 [L]** 감사 리포트 판정 배너와 `CLAUDE.md` 의 "43/43" 표기가 stale.
|
|
||||||
DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
|
|
||||||
|
|
||||||
### 재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
|
|
||||||
|
|
||||||
- **웹훅 SSRF (내부망 주소 지정 가능)** — 채널 등록은 admin 전용이고, 이 기능의 정당한
|
|
||||||
용도가 바로 **사내망** 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가
|
|
||||||
성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
|
|
||||||
- **서버가 엣지 GPIO 핀 번호를 지정** — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는
|
|
||||||
이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다.
|
|
||||||
이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
|
|
||||||
- **`/setup` 4단계가 첫 카메라의 존만 확인** — 다른 카메라에 존이 있어도 "없음"으로 보일 수
|
|
||||||
있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이
|
|
||||||
N+1 요청을 내는 대가가 이득보다 크다고 판단. 수용.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
|
|
||||||
|
|
||||||
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
|
|
||||||
|
|
||||||
- [ ] **GPIO 경광등 실동작** — `edge/beacon.py` 의 핀 구동은 단위 테스트로 증명할 수 없다.
|
|
||||||
Pi + 릴레이 모듈로 latch/pulse, `active_high` 반전, hold 만료 해제를 육안 확인.
|
|
||||||
- [ ] **카메라 무결성 임계값** — `IntegrityConfig.moved_mad` / `obstructed_var` /
|
|
||||||
`low_light_mean` 은 실제 현장 조명·진동에서 튜닝해야 한다. 현재 값은 합성 프레임 기준.
|
|
||||||
- [ ] **P2-6/7/8** — 낙상 타이밍·트래킹 매칭 (1차 감사에서 이월). 블라인드 수정은 감지 **누락**을
|
|
||||||
만들 수 있어 실영상 확보 후 조정.
|
|
||||||
- [ ] **CSI 카메라 / Hailo HEF 추론** — `tests/hardware/` 스킵 항목.
|
|
||||||
- [ ] **통보 실발송** — 사내 SMTP 릴레이·웹훅 엔드포인트는 현장에만 있다.
|
|
||||||
`dispatch_alerts.php --dry-run` 으로 대상만 먼저 확인한 뒤 1회 실발송 검증.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
(updated as each item lands — newest first)
|
(updated as each item lands — newest first)
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ return [
|
||||||
'public_base_url' => '', // 예: 'https://example.com/move_detect'
|
'public_base_url' => '', // 예: 'https://example.com/move_detect'
|
||||||
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
|
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
|
||||||
'escalate_after_seconds' => 300,
|
'escalate_after_seconds' => 300,
|
||||||
// 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가
|
|
||||||
// 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준
|
|
||||||
// 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다.
|
|
||||||
'timezone' => 'Asia/Seoul',
|
|
||||||
|
|
||||||
// --- 보존 / 용량 ------------------------------------------------------------------
|
// --- 보존 / 용량 ------------------------------------------------------------------
|
||||||
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
|
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,6 @@ return [
|
||||||
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
|
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
|
||||||
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
|
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
|
||||||
'escalate_after_seconds' => 300,
|
'escalate_after_seconds' => 300,
|
||||||
// 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC
|
|
||||||
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
|
|
||||||
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
|
|
||||||
'timezone' => 'Asia/Seoul',
|
|
||||||
|
|
||||||
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
|
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
|
||||||
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
|
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
|
||||||
|
|
|
||||||
|
|
@ -170,35 +170,12 @@ ALTER TABLE move_actions
|
||||||
ADD KEY ix_actions_event (event_id);
|
ADD KEY ix_actions_event (event_id);
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
|
-- 9. 신규 감지 유형
|
||||||
--
|
|
||||||
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
|
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
|
||||||
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
|
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
|
||||||
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
|
-- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음).
|
||||||
--
|
|
||||||
-- 이 CHECK 는 **2차 방어선**이지 검증 계층이 아닙니다. 1차 검증은 애플리케이션의
|
|
||||||
-- App\Ingest\EventValidator 이고(그쪽은 이미 신규 유형을 받습니다), tests/test_event_type_parity.py
|
|
||||||
-- 가 둘의 일치를 강제합니다. 따라서 이 블록이 실패하거나 건너뛰어져도 시스템은 정상 동작합니다.
|
|
||||||
-- 다만 그 경우 **신규 유형의 INSERT 가 제약 위반으로 거부**되므로, 아래 셋 중 맞는 것을 반드시
|
|
||||||
-- 하나 실행하십시오.
|
|
||||||
--
|
|
||||||
-- (A) MySQL 8.0.16 이상 — `DROP CHECK` 지원:
|
|
||||||
-- ALTER TABLE move_events DROP CHECK chk_events_type;
|
|
||||||
--
|
|
||||||
-- (B) MariaDB (10.2.22+) — `DROP CHECK` 없음, `DROP CONSTRAINT` 사용:
|
|
||||||
-- ALTER TABLE move_events DROP CONSTRAINT chk_events_type;
|
|
||||||
--
|
|
||||||
-- (C) 제약이 애초에 없는 환경 (MySQL 5.7 은 CHECK 를 파싱만 하고 무시했습니다):
|
|
||||||
-- DROP 을 건너뛰고 아래 ADD 만 실행하십시오. ADD 가 "이미 존재" 오류를 내면
|
|
||||||
-- 그 환경에는 제약이 살아 있는 것이므로 (A) 또는 (B) 로 돌아가십시오.
|
|
||||||
--
|
|
||||||
-- 어느 것을 실행해야 할지 모르겠다면 먼저 확인하십시오:
|
|
||||||
-- SELECT VERSION();
|
|
||||||
--
|
|
||||||
-- 아래 DROP 은 (A) 기준으로 적어 두었습니다. MariaDB 라면 이 한 줄을 (B) 로 바꾸세요.
|
|
||||||
ALTER TABLE move_events
|
ALTER TABLE move_events
|
||||||
DROP CHECK chk_events_type;
|
DROP CHECK chk_events_type;
|
||||||
|
|
||||||
ALTER TABLE move_events
|
ALTER TABLE move_events
|
||||||
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||||
'fall','no_motion','immobility','ghost','zone_warning',
|
'fall','no_motion','immobility','ghost','zone_warning',
|
||||||
|
|
@ -210,9 +187,5 @@ ALTER TABLE move_events
|
||||||
|
|
||||||
-- 검증:
|
-- 검증:
|
||||||
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
|
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
|
||||||
-- php tools/verify_audit_chain.php -- 사슬 무결성
|
-- php server/tools/verify_audit_chain.php -- 사슬 무결성
|
||||||
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
|
-- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';
|
||||||
-- -- 신규 유형이 실제로 들어가는지 (넣고 바로 지웁니다):
|
|
||||||
-- INSERT INTO move_events (device_id,camera_id,event_type,severity,occurred_at,received_at)
|
|
||||||
-- VALUES ('_migration_probe','_probe','camera_moved','high',0,0);
|
|
||||||
-- DELETE FROM move_events WHERE device_id = '_migration_probe';
|
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,7 @@
|
||||||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||||||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
||||||
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
|
|
||||||
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
|
||||||
unacked_escalation: '미확인 경보 에스컬레이션',
|
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var SEVERITY_LABELS = {
|
var SEVERITY_LABELS = {
|
||||||
|
|
@ -1074,36 +1069,17 @@
|
||||||
disarmBulk();
|
disarmBulk();
|
||||||
bulkBtn.disabled = true;
|
bulkBtn.disabled = true;
|
||||||
var done = 0;
|
var done = 0;
|
||||||
var queue = targets.slice();
|
Promise.all(targets.map(function (ev) {
|
||||||
// Bounded concurrency. Firing one fetch per target looked fine at 7 rows, but a real
|
|
||||||
// backlog is hundreds, and over HTTP/2 the browser does NOT serialise them — the whole
|
|
||||||
// sweep lands on shared hosting at once and the later writes come back 503. Four at a
|
|
||||||
// time keeps it well inside any process limit and still clears 200 rows in seconds.
|
|
||||||
var CONCURRENCY = 4;
|
|
||||||
|
|
||||||
function worker() {
|
|
||||||
var ev = queue.shift();
|
|
||||||
if (!ev) return Promise.resolve();
|
|
||||||
return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', {
|
return fetch(B + '/events/' + encodeURIComponent(ev.event_id) + '/ack', {
|
||||||
method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' }
|
method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||||
})
|
}).then(function (res) {
|
||||||
.then(function (res) { if (res.ok) { patchEventLocally(ev.event_id, false); done++; } })
|
if (res.ok) { patchEventLocally(ev.event_id, false); done++; }
|
||||||
.catch(function () {})
|
}).catch(function () {});
|
||||||
.then(function () {
|
})).then(function () {
|
||||||
// Repaint as it goes: a sweep that shows nothing until it finishes reads as frozen.
|
|
||||||
lastListSig = null;
|
|
||||||
renderList();
|
|
||||||
return worker();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var lanes = [];
|
|
||||||
for (var i = 0; i < Math.min(CONCURRENCY, targets.length); i++) lanes.push(worker());
|
|
||||||
Promise.all(lanes).then(function () {
|
|
||||||
bulkBtn.disabled = false;
|
bulkBtn.disabled = false;
|
||||||
lastListSig = null;
|
lastListSig = null;
|
||||||
renderList();
|
renderList();
|
||||||
showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)');
|
showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)');
|
||||||
loadEvents();
|
loadEvents();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,7 @@
|
||||||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||||||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
||||||
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
|
|
||||||
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
|
||||||
unacked_escalation: '미확인 경보 에스컬레이션',
|
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
|
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,7 @@
|
||||||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||||
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
|
||||||
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트',
|
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
|
||||||
// 감시 자체의 건전성 — 존 판정이 무의미해졌음을 알리는 유형들.
|
|
||||||
camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하',
|
|
||||||
unacked_escalation: '미확인 경보 에스컬레이션',
|
|
||||||
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
|
|
||||||
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
|
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,6 @@ final class AlertDispatcher
|
||||||
// One channel read per site, not per event: a busy window is hundreds of events over a
|
// One channel read per site, not per event: a busy window is hundreds of events over a
|
||||||
// handful of sites, and this job runs on shared hosting where every query costs.
|
// handful of sites, and this job runs on shared hosting where every query costs.
|
||||||
$channelsBySite = [];
|
$channelsBySite = [];
|
||||||
// Everything already in the ledger for this window, in ONE query. Without it the run
|
|
||||||
// re-attempts claim() for every pair it delivered on every previous run — an INSERT that
|
|
||||||
// fails on the unique key, once per (event × channel), once a minute, forever. claim()
|
|
||||||
// stays the authority (two runs can still overlap between this read and the write); this
|
|
||||||
// is purely the cheap pre-filter that keeps the cost proportional to NEW events.
|
|
||||||
$alreadyClaimed = $this->alerts->claimedSince($now - $windowSeconds - 60);
|
|
||||||
|
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
$stats['considered']++;
|
$stats['considered']++;
|
||||||
|
|
@ -111,12 +105,8 @@ final class AlertDispatcher
|
||||||
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
|
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
|
||||||
$channelId = (int)$delivery['channel']['channel_id'];
|
$channelId = (int)$delivery['channel']['channel_id'];
|
||||||
$eventId = (int)$event['event_id'];
|
$eventId = (int)$event['event_id'];
|
||||||
// Cheap pre-filter (see $alreadyClaimed) — skips the overwhelming majority of
|
// claim() is the duplicate guard. Losing it means another (possibly overlapping)
|
||||||
// pairs on every run after the first.
|
// dispatcher run owns this delivery — skip, do not send.
|
||||||
if (isset($alreadyClaimed[$eventId . ':' . $channelId])) { $stats['skipped']++; continue; }
|
|
||||||
// claim() remains the authority: the pre-filter is a snapshot, and a concurrent
|
|
||||||
// run can claim a pair between that read and this write. Losing the race means
|
|
||||||
// someone else owns this delivery — skip, do not send.
|
|
||||||
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
|
if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; }
|
||||||
if ($delivery['suppressed']) {
|
if ($delivery['suppressed']) {
|
||||||
$this->alerts->markSuppressed($eventId, $channelId, $now);
|
$this->alerts->markSuppressed($eventId, $channelId, $now);
|
||||||
|
|
|
||||||
|
|
@ -39,30 +39,8 @@ final class Channels
|
||||||
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
|
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/** @param string $baseUrl absolute dashboard URL, for the incident link. */
|
||||||
* @param string $baseUrl absolute dashboard URL, for the incident link.
|
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
|
||||||
* @param string $timezone display timezone for the stamps in the message. The dashboard
|
|
||||||
* formats time in the BROWSER, so it always reads local; a notification is composed
|
|
||||||
* by PHP, and shared hosting commonly leaves date.timezone at UTC — which printed
|
|
||||||
* "04:01" for the fall the console showed at "13:01".
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private string $baseUrl,
|
|
||||||
private int $timeoutSeconds = 8,
|
|
||||||
private string $timezone = 'Asia/Seoul',
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Wall-clock stamp in the configured zone, without touching the process-wide default. */
|
|
||||||
private function stamp(int $ts): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return (new \DateTimeImmutable('@' . $ts))
|
|
||||||
->setTimezone(new \DateTimeZone($this->timezone))
|
|
||||||
->format('Y-m-d H:i:s');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return date('Y-m-d H:i:s', $ts); // never let a bad zone stop an alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send(array $event, array $channel): void
|
public function send(array $event, array $channel): void
|
||||||
{
|
{
|
||||||
|
|
@ -89,7 +67,7 @@ final class Channels
|
||||||
{
|
{
|
||||||
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
||||||
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
||||||
$when = $this->stamp((int)$event['occurred_at']);
|
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
|
||||||
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
|
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
"작업장 안전 감지 알림",
|
"작업장 안전 감지 알림",
|
||||||
|
|
|
||||||
|
|
@ -35,24 +35,6 @@ final class Config
|
||||||
*/
|
*/
|
||||||
public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); }
|
public function publicBaseUrl(): string { return rtrim((string)($this->a['public_base_url'] ?? ''), '/'); }
|
||||||
|
|
||||||
/**
|
|
||||||
* Display timezone for every human-facing timestamp the SERVER renders — which today means
|
|
||||||
* the notification messages (App\Alert\Channels).
|
|
||||||
*
|
|
||||||
* The dashboard formats time in the browser, so it always reads local. A notification does
|
|
||||||
* not: it is composed by PHP, and shared hosting frequently leaves date.timezone at UTC. The
|
|
||||||
* result was a mail saying "04:01" for the fall the console showed at "13:01" — a nine-hour
|
|
||||||
* discrepancy in the one message a responder reads under pressure, with nothing on either
|
|
||||||
* screen to explain it. Defaults to Asia/Seoul because that is where this product is
|
|
||||||
* deployed; an invalid value falls back rather than throwing, since a bad timezone must not
|
|
||||||
* stop an alert from going out.
|
|
||||||
*/
|
|
||||||
public function timezone(): string
|
|
||||||
{
|
|
||||||
$tz = (string)($this->a['timezone'] ?? 'Asia/Seoul');
|
|
||||||
return in_array($tz, \DateTimeZone::listIdentifiers(), true) ? $tz : 'Asia/Seoul';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long an unresolved critical/high event may sit before it becomes an
|
* How long an unresolved critical/high event may sit before it becomes an
|
||||||
* `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by
|
* `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by
|
||||||
|
|
|
||||||
|
|
@ -163,18 +163,12 @@ final class AdminConfigController
|
||||||
if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
|
if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
|
||||||
|
|
||||||
$id = (int)($b['channel_id'] ?? 0);
|
$id = (int)($b['channel_id'] ?? 0);
|
||||||
$site = $this->site(); // new channels belong to the caller's scope
|
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$existing = null;
|
$existing = null;
|
||||||
foreach ($this->alerts->allChannels($this->site()) as $c) {
|
foreach ($this->alerts->allChannels($this->site()) as $c) {
|
||||||
if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
|
if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
|
||||||
}
|
}
|
||||||
if ($existing === null) return Response::json(['error' => 'not_found'], 404);
|
if ($existing === null) return Response::json(['error' => 'not_found'], 404);
|
||||||
// Keep the channel's own site. A GLOBAL admin (site_scope null) editing a
|
|
||||||
// site-scoped channel would otherwise rewrite site to NULL and silently promote it
|
|
||||||
// to every site — the same channel then fires for factories it was never meant to
|
|
||||||
// reach, and nothing on the form says so.
|
|
||||||
$site = $existing['site'];
|
|
||||||
// A masked token means "leave it alone" — the GET never revealed the real one.
|
// A masked token means "leave it alone" — the GET never revealed the real one.
|
||||||
if (($config['token'] ?? null) === '••••••••') {
|
if (($config['token'] ?? null) === '••••••••') {
|
||||||
$old = json_decode($existing['config_json'], true);
|
$old = json_decode($existing['config_json'], true);
|
||||||
|
|
@ -185,7 +179,7 @@ final class AdminConfigController
|
||||||
|
|
||||||
$newId = $this->alerts->saveChannel([
|
$newId = $this->alerts->saveChannel([
|
||||||
'channel_id' => $id > 0 ? $id : null,
|
'channel_id' => $id > 0 ? $id : null,
|
||||||
'site' => $site,
|
'site' => $this->site(),
|
||||||
'kind' => $kind,
|
'kind' => $kind,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,9 @@ namespace App\Ingest;
|
||||||
|
|
||||||
final class EventValidator
|
final class EventValidator
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Accepted wire codes. MUST stay in sync with three other places, and the sync is not
|
|
||||||
* enforced by anything but review — RouteTableTest cross-checks routes, but nothing
|
|
||||||
* cross-checks this list:
|
|
||||||
* - edge/events.py EventType (what the edge can emit)
|
|
||||||
* - server/schema.sql chk_events_type (defense-in-depth CHECK)
|
|
||||||
* - the front-end TYPE_LABELS maps (app.js / detail.js / report.js / compliance.js /
|
|
||||||
* package.js) and the 유형 filter in View/events.php
|
|
||||||
*
|
|
||||||
* A type missing HERE is the worst of the four: the edge emits it, the server answers 400,
|
|
||||||
* and edge/queue.py retries the row 20 times before dropping it — so the detector looks
|
|
||||||
* implemented, is fully tested on the device, and produces nothing. That is exactly what
|
|
||||||
* happened to camera_moved/camera_obstructed/low_light when they were added.
|
|
||||||
*/
|
|
||||||
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
|
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
|
||||||
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
'zone_restricted','pinch','monitoring_degraded','device_fault',
|
||||||
'lone_worker','overcapacity','overstay','near_fall','ergo_report',
|
'lone_worker','overcapacity','overstay','near_fall','ergo_report'];
|
||||||
// 감시 자체의 건전성 (edge/detectors/integrity.py)
|
|
||||||
'camera_moved','camera_obstructed','low_light',
|
|
||||||
// 서버가 발행하지만(EscalationWatch, 검증기를 거치지 않음) 유형 집합은 한 곳에 모은다.
|
|
||||||
'unacked_escalation',
|
|
||||||
// 모델 확보 후 활성화 (edge/detectors/pending.py) — 지금은 엣지가 발화하지 않는다.
|
|
||||||
'ppe_missing','proximity','fire_smoke'];
|
|
||||||
private const SEVERITIES = ['critical','high','warning','info'];
|
private const SEVERITIES = ['critical','high','warning','info'];
|
||||||
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];
|
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,4 @@ interface AlertRepository
|
||||||
|
|
||||||
/** Rows still worth retrying (status=failed, attempts under the cap). */
|
/** Rows still worth retrying (status=failed, attempts under the cap). */
|
||||||
public function retryable(int $maxAttempts, int $limit): array;
|
public function retryable(int $maxAttempts, int $limit): array;
|
||||||
|
|
||||||
/**
|
|
||||||
* (event_id, channel_id) pairs already in the ledger for events at/after $sinceQueuedAt,
|
|
||||||
* as a set keyed "eventId:channelId".
|
|
||||||
*
|
|
||||||
* The dispatcher reconsiders its whole window every minute, so without this it re-attempts
|
|
||||||
* claim() for every already-delivered pair on every run — one failing INSERT each. The cost
|
|
||||||
* scales with the WINDOW rather than with new events, so widening the window (which a site
|
|
||||||
* does exactly when cron has been unreliable) multiplies the load instead of the coverage.
|
|
||||||
*/
|
|
||||||
public function claimedSince(int $sinceQueuedAt): array;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,30 +160,6 @@ final class PdoAlertRepository implements AlertRepository
|
||||||
], $st->fetchAll());
|
], $st->fetchAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function claimedSince(int $sinceQueuedAt): array
|
|
||||||
{
|
|
||||||
$st = $this->pdo->prepare(
|
|
||||||
'SELECT event_id, channel_id FROM move_alert_log WHERE queued_at >= ?'
|
|
||||||
);
|
|
||||||
$st->execute([$sinceQueuedAt]);
|
|
||||||
$out = [];
|
|
||||||
foreach ($st->fetchAll() as $r) {
|
|
||||||
$out[$r['event_id'] . ':' . $r['channel_id']] = true;
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retention for the delivery ledger. Resolved rows only — a failed delivery is kept until
|
|
||||||
* it is retried or ages past the cap, because "we never reached anyone" is the finding. */
|
|
||||||
public function pruneResolved(int $before): int
|
|
||||||
{
|
|
||||||
$st = $this->pdo->prepare(
|
|
||||||
"DELETE FROM move_alert_log WHERE queued_at < ? AND status IN ('sent','suppressed')"
|
|
||||||
);
|
|
||||||
$st->execute([$before]);
|
|
||||||
return $st->rowCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isDuplicate(PDOException $e): bool
|
private static function isDuplicate(PDOException $e): bool
|
||||||
{
|
{
|
||||||
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
|
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
|
||||||
|
|
|
||||||
|
|
@ -43,16 +43,6 @@ render_header('이벤트');
|
||||||
<option value="ergo_report">작업자세 부담 리포트</option>
|
<option value="ergo_report">작업자세 부담 리포트</option>
|
||||||
<option value="monitoring_degraded">감시 성능 저하</option>
|
<option value="monitoring_degraded">감시 성능 저하</option>
|
||||||
<option value="device_fault">장치 이상</option>
|
<option value="device_fault">장치 이상</option>
|
||||||
<!-- 감시 자체의 건전성. 조사자가 "언제부터 저 카메라가 이상했나"만 따로 걸러
|
|
||||||
볼 수 있어야 한다. -->
|
|
||||||
<option value="camera_moved">카메라 화각 변경</option>
|
|
||||||
<option value="camera_obstructed">카메라 가림</option>
|
|
||||||
<option value="low_light">조도 저하</option>
|
|
||||||
<option value="unacked_escalation">미확인 경보 에스컬레이션</option>
|
|
||||||
<!-- 모델 확보 후 활성화 (엣지는 아직 발화하지 않음). -->
|
|
||||||
<option value="ppe_missing">보호구 미착용</option>
|
|
||||||
<option value="proximity">중장비 근접</option>
|
|
||||||
<option value="fire_smoke">화재·연기</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -233,30 +233,6 @@ final class AlertDispatchTest extends TestCase
|
||||||
$this->assertStringContainsString('이미지가 포함되지 않습니다', $body);
|
$this->assertStringContainsString('이미지가 포함되지 않습니다', $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 재감사(2026-08-18) 회귀 ------------------------------------------------
|
|
||||||
|
|
||||||
public function testAlreadyDeliveredPairsAreNotReClaimedEveryRun(): void
|
|
||||||
{
|
|
||||||
// A7: 디스패처는 매분 창 전체를 다시 훑는다. 사전 필터가 없으면 이미 보낸
|
|
||||||
// (event,channel) 마다 실패할 INSERT 를 한 번씩 또 시도한다 — 비용이 '새 이벤트'가
|
|
||||||
// 아니라 '창 길이'에 비례하고, 크론이 불안정해 창을 넓힌 현장일수록 더 나빠진다.
|
|
||||||
$events = new InMemoryEventRepository();
|
|
||||||
$events->insert(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'event_type' => 'fall',
|
|
||||||
'severity' => 'critical', 'occurred_at' => (float)(self::NOW - 10), 'metrics' => []]);
|
|
||||||
$alerts = new InMemoryAlertRepository([$this->channel()]);
|
|
||||||
$d = $this->dispatcher($events, $alerts);
|
|
||||||
$send = function () {};
|
|
||||||
|
|
||||||
$d->run(self::NOW, 3600, 100, $send);
|
|
||||||
$afterFirst = $alerts->claimCalls;
|
|
||||||
$d->run(self::NOW, 3600, 100, $send);
|
|
||||||
$d->run(self::NOW, 3600, 100, $send);
|
|
||||||
|
|
||||||
$this->assertSame(1, $afterFirst, '첫 실행은 한 번 claim 한다');
|
|
||||||
$this->assertSame(1, $alerts->claimCalls,
|
|
||||||
'이후 실행은 원장을 먼저 읽어 이미 처리된 쌍에 claim 을 시도하지 않는다');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWebhookRefusesNonHttpSchemes(): void
|
public function testWebhookRefusesNonHttpSchemes(): void
|
||||||
{
|
{
|
||||||
$c = new Channels('https://example.com');
|
$c = new Channels('https://example.com');
|
||||||
|
|
|
||||||
|
|
@ -181,34 +181,6 @@ final class SafetyConfigTest extends TestCase
|
||||||
$this->assertSame(400, $res->status());
|
$this->assertSame(400, $res->status());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEditingAChannelKeepsItsSiteInsteadOfPromotingItGlobal(): void
|
|
||||||
{
|
|
||||||
// 재감사(A9): 전역 관리자(site_scope null)가 현장 채널을 수정하면 site 가 NULL 로
|
|
||||||
// 덮여 그 채널이 모든 현장에 발화하게 됐다. 폼 어디에도 그런 말은 없다.
|
|
||||||
$alerts = new InMemoryAlertRepository([[
|
|
||||||
'channel_id' => 9, 'site' => 'factory-a', 'kind' => 'email', 'name' => 'A동 담당',
|
|
||||||
'config_json' => '{"to":["a@b.c"]}', 'min_severity' => 'high',
|
|
||||||
'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0,
|
|
||||||
]]);
|
|
||||||
$c = $this->admin('admin', null, null, null, null, $alerts); // 전역 관리자
|
|
||||||
$res = $c->saveChannel($this->post(['channel_id' => 9, 'kind' => 'email',
|
|
||||||
'name' => 'A동 담당 (수정)', 'enabled' => 1, 'min_severity' => 'critical',
|
|
||||||
'config' => ['to' => 'a@b.c']]));
|
|
||||||
$this->assertSame(200, $res->status());
|
|
||||||
$this->assertSame('factory-a', $alerts->chan[9]['site'],
|
|
||||||
'수정해도 채널은 자기 현장에 남아야 한다');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testANewChannelBelongsToTheCallersScope(): void
|
|
||||||
{
|
|
||||||
$alerts = new InMemoryAlertRepository();
|
|
||||||
$c = $this->admin('admin', 'factory-b', null, null, null, $alerts);
|
|
||||||
$res = $c->saveChannel($this->post(['kind' => 'email', 'name' => 'B동', 'enabled' => 1,
|
|
||||||
'min_severity' => 'high', 'config' => ['to' => 'b@b.c']]));
|
|
||||||
$id = json_decode($res->body(), true)['channel_id'];
|
|
||||||
$this->assertSame('factory-b', $alerts->chan[$id]['site']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEmailChannelNeedsAValidRecipient(): void
|
public function testEmailChannelNeedsAValidRecipient(): void
|
||||||
{
|
{
|
||||||
$c = $this->admin();
|
$c = $this->admin();
|
||||||
|
|
|
||||||
|
|
@ -362,10 +362,6 @@ final class InMemorySuppressionRepository implements SuppressionRepository {
|
||||||
}
|
}
|
||||||
final class InMemoryAlertRepository implements AlertRepository {
|
final class InMemoryAlertRepository implements AlertRepository {
|
||||||
public array $chan = []; public array $log = []; private int $seq = 0;
|
public array $chan = []; public array $log = []; private int $seq = 0;
|
||||||
/** Observation hook: how many times claim() was actually attempted. The dispatcher is
|
|
||||||
* supposed to pre-filter already-delivered pairs, and the only way to see that from the
|
|
||||||
* outside is to count the attempts. */
|
|
||||||
public int $claimCalls = 0;
|
|
||||||
public function __construct(array $channels = []) { foreach ($channels as $c) $this->saveChannel($c); }
|
public function __construct(array $channels = []) { foreach ($channels as $c) $this->saveChannel($c); }
|
||||||
public function channels(?string $s): array {
|
public function channels(?string $s): array {
|
||||||
$r = array_values(array_filter($this->chan, fn($c) =>
|
$r = array_values(array_filter($this->chan, fn($c) =>
|
||||||
|
|
@ -389,7 +385,6 @@ final class InMemoryAlertRepository implements AlertRepository {
|
||||||
}
|
}
|
||||||
private function key(int $e, int $c): string { return $e . ':' . $c; }
|
private function key(int $e, int $c): string { return $e . ':' . $c; }
|
||||||
public function claim(int $e, int $c, int $now): bool {
|
public function claim(int $e, int $c, int $now): bool {
|
||||||
$this->claimCalls++;
|
|
||||||
if (isset($this->log[$this->key($e, $c)])) return false;
|
if (isset($this->log[$this->key($e, $c)])) return false;
|
||||||
$this->log[$this->key($e, $c)] = ['event_id' => $e, 'channel_id' => $c, 'status' => 'pending',
|
$this->log[$this->key($e, $c)] = ['event_id' => $e, 'channel_id' => $c, 'status' => 'pending',
|
||||||
'attempts' => 0, 'last_error' => null, 'queued_at' => $now, 'sent_at' => null];
|
'attempts' => 0, 'last_error' => null, 'queued_at' => $now, 'sent_at' => null];
|
||||||
|
|
@ -414,19 +409,6 @@ final class InMemoryAlertRepository implements AlertRepository {
|
||||||
return array_slice(array_values(array_filter($this->log,
|
return array_slice(array_values(array_filter($this->log,
|
||||||
fn($r) => $r['status'] === 'failed' && $r['attempts'] < $max)), 0, $limit);
|
fn($r) => $r['status'] === 'failed' && $r['attempts'] < $max)), 0, $limit);
|
||||||
}
|
}
|
||||||
public function claimedSince(int $since): array {
|
|
||||||
$out = [];
|
|
||||||
foreach ($this->log as $r) {
|
|
||||||
if ($r['queued_at'] >= $since) $out[$r['event_id'] . ':' . $r['channel_id']] = true;
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
public function pruneResolved(int $before): int {
|
|
||||||
$n = count($this->log);
|
|
||||||
$this->log = array_filter($this->log, fn($r) =>
|
|
||||||
!($r['queued_at'] < $before && in_array($r['status'], ['sent', 'suppressed'], true)));
|
|
||||||
return $n - count($this->log);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
final class InMemoryHandoverRepository implements HandoverRepository {
|
final class InMemoryHandoverRepository implements HandoverRepository {
|
||||||
public array $rows = []; private int $seq = 0;
|
public array $rows = []; private int $seq = 0;
|
||||||
|
|
|
||||||
|
|
@ -34,28 +34,12 @@ if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) {
|
||||||
fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n");
|
fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
// 백업 파일에는 전 현장의 사고 기록과 작업자 이미지(개인정보)가 들어 있다. 웹에서 읽히는
|
// 백업 파일에는 전 현장의 사고 기록과 개인정보가 들어 있다. 웹에서 읽히는 위치에 두면
|
||||||
// 위치에 두면 그 자체가 사고다 — 파일명은 `move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만
|
// 그 자체가 사고다.
|
||||||
// 알면 추측 가능하다.
|
|
||||||
//
|
|
||||||
// 감사 지적(A3): 예전 검사는 `str_contains($real, '/www/')` 였다. 하위 경로
|
|
||||||
// (/home/acct/www/backups)는 막았지만 **docroot 자체**(/home/acct/www)는 슬래시가 뒤에
|
|
||||||
// 없어 그대로 통과했다. 가장 흔한 오사용이 정확히 그 케이스다.
|
|
||||||
// 이제 경로를 세그먼트로 쪼개 웹 루트 이름이 하나라도 있으면 거부한다.
|
|
||||||
const WEB_DIR_NAMES = ['www', 'public_html', 'htdocs', 'html', 'web', 'public'];
|
|
||||||
$real = realpath($outDir);
|
$real = realpath($outDir);
|
||||||
if ($real !== false) {
|
if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) {
|
||||||
$segments = array_filter(explode('/', str_replace('\\', '/', $real)), 'strlen');
|
fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n");
|
||||||
foreach ($segments as $seg) {
|
|
||||||
if (in_array(strtolower($seg), WEB_DIR_NAMES, true)) {
|
|
||||||
fwrite(STDERR,
|
|
||||||
"거부: 백업 대상 경로에 웹 공개 디렉터리('{$seg}')가 포함되어 있습니다.\n"
|
|
||||||
. " 경로: {$real}\n"
|
|
||||||
. " 이 파일은 전 현장의 사고 기록과 작업자 이미지를 담습니다. www/ 밖(FTP 최상위\n"
|
|
||||||
. " 또는 그 상위)의 비공개 디렉터리를 지정하세요.\n");
|
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stamp = date('Ymd-His');
|
$stamp = date('Ymd-His');
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ if ($baseUrl === '') {
|
||||||
fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n");
|
fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
$channels = new Channels($baseUrl, 8, $cfg->timezone());
|
$channels = new Channels($baseUrl);
|
||||||
|
|
||||||
// --- 1. 에스컬레이션 발행 -------------------------------------------------------------
|
// --- 1. 에스컬레이션 발행 -------------------------------------------------------------
|
||||||
$watch = new EscalationWatch($events);
|
$watch = new EscalationWatch($events);
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/bootstrap.php';
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository,
|
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository};
|
||||||
PdoAlertRepository};
|
|
||||||
|
|
||||||
[$cfg, $pdo] = tool_boot();
|
[$cfg, $pdo] = tool_boot();
|
||||||
$args = tool_args($argv);
|
$args = tool_args($argv);
|
||||||
|
|
@ -63,21 +62,6 @@ if ($metricDays > 0) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 통보 원장 -------------------------------------------------------------------------
|
|
||||||
// 해소된 전달만 지운다. status='failed' 는 남긴다 — "아무에게도 닿지 않았다"는 사실 자체가
|
|
||||||
// 조사에서 필요한 발견이고, 그것을 청소 작업이 조용히 지워서는 안 된다.
|
|
||||||
// 이벤트 보존이 꺼져 있으면(기본값) 이 표는 유일하게 무한 증가하는 표가 된다.
|
|
||||||
$alertDays = max(30, $metricDays); // 성능 시계열보다 짧게 잡을 이유가 없다
|
|
||||||
$cutoff = $now - $alertDays * 86400;
|
|
||||||
if ($dry) {
|
|
||||||
$st = $pdo->prepare("SELECT COUNT(*) FROM move_alert_log
|
|
||||||
WHERE queued_at < ? AND status IN ('sent','suppressed')");
|
|
||||||
$st->execute([$cutoff]);
|
|
||||||
$done[] = 'alert_log(dry) ' . (int)$st->fetchColumn();
|
|
||||||
} else {
|
|
||||||
$done[] = 'alert_log ' . (new PdoAlertRepository($pdo))->pruneResolved($cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- nonce ---------------------------------------------------------------------------
|
// ---- nonce ---------------------------------------------------------------------------
|
||||||
if ($dry) {
|
if ($dry) {
|
||||||
$st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?');
|
$st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?');
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,9 @@ require __DIR__ . '/bootstrap.php';
|
||||||
[$cfg, $pdo] = tool_boot();
|
[$cfg, $pdo] = tool_boot();
|
||||||
$args = tool_args($argv);
|
$args = tool_args($argv);
|
||||||
|
|
||||||
// Demo rows carry a marker in a field no real path ever writes, so --purge can target exactly
|
// Demo rows are marked in the one field no ingest path writes: a device_id prefix. That makes
|
||||||
// those rows and nothing else. This is not cosmetic: a purge that matches on a plausible real
|
// --purge exact instead of "delete everything and hope".
|
||||||
// value (an operator name, a site name) will one day be run against a production database and
|
const DEMO_PREFIX = 'demo-';
|
||||||
// delete records nobody can reconstruct.
|
|
||||||
const DEMO_PREFIX = 'demo-'; // device_id prefix
|
|
||||||
const DEMO_ACTOR = 'demo-seed'; // created_by / reviewed_by
|
|
||||||
const DEMO_LABEL = '[데모] '; // name prefix on channels
|
|
||||||
|
|
||||||
$days = max(1, min(365, (int)($args['days'] ?? 45)));
|
$days = max(1, min(365, (int)($args['days'] ?? 45)));
|
||||||
$force = isset($args['force']);
|
$force = isset($args['force']);
|
||||||
|
|
@ -66,15 +62,11 @@ function purge(PDO $pdo, string $snapshotDir): void
|
||||||
$pdo->prepare('DELETE FROM move_devices WHERE device_id LIKE ?')->execute([$like]);
|
$pdo->prepare('DELETE FROM move_devices WHERE device_id LIKE ?')->execute([$like]);
|
||||||
$pdo->prepare('DELETE FROM move_cameras WHERE device_id LIKE ?')->execute([$like]);
|
$pdo->prepare('DELETE FROM move_cameras WHERE device_id LIKE ?')->execute([$like]);
|
||||||
$pdo->prepare('DELETE FROM move_device_metrics WHERE device_id LIKE ?')->execute([$like]);
|
$pdo->prepare('DELETE FROM move_device_metrics WHERE device_id LIKE ?')->execute([$like]);
|
||||||
$pdo->prepare("DELETE FROM move_actions WHERE created_by = ?")->execute([DEMO_ACTOR]);
|
$pdo->prepare("DELETE FROM move_actions WHERE created_by = 'demo-seed'")->execute();
|
||||||
// 데모 행은 반드시 DEMO_ACTOR / DEMO_PREFIX 표식으로만 식별한다.
|
// 데모 채널/위험요인은 이름·검토자에 표식이 있다. 실제 운영 행을 지우지 않도록
|
||||||
//
|
// LIKE 로 좁힌다 — 시연용 DB 와 운영 DB 가 같은 호스트에 있는 경우가 실제로 있다.
|
||||||
// 감사 지적(A2): 예전에는 위험요인을 `reviewed_by='안전관리자' AND site='factory-a'` 로
|
$pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE '%(데모%'")->execute();
|
||||||
// 지웠다. '안전관리자'는 이 제품의 기본 관리자 계정명이고 'factory-a'는 흔한 현장명이라,
|
$pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = '안전관리자' AND site = 'factory-a'")->execute();
|
||||||
// 운영 DB 에서 --purge 를 한 번 돌리면 **실제 위험성평가가 통째로 사라진다.** 표식 없는
|
|
||||||
// 행은 이제 어떤 경우에도 건드리지 않는다.
|
|
||||||
$pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE ?")->execute([DEMO_LABEL . '%']);
|
|
||||||
$pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = ?")->execute([DEMO_ACTOR]);
|
|
||||||
say("purged demo rows (snapshot files removed: $removed)");
|
say("purged demo rows (snapshot files removed: $removed)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,7 +259,7 @@ foreach ([
|
||||||
['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12],
|
['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12],
|
||||||
['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5],
|
['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5],
|
||||||
] as [$c, $note, $ago]) {
|
] as [$c, $note, $ago]) {
|
||||||
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, DEMO_ACTOR, $now - $ago * 86400]);
|
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, 'demo-seed', $now - $ago * 86400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 통보 · 규정 · 성능
|
// ---------------------------------------------------------------- 통보 · 규정 · 성능
|
||||||
|
|
@ -275,13 +267,13 @@ foreach ([
|
||||||
// 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다.
|
// 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다.
|
||||||
$ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at)
|
$ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)');
|
VALUES (?,?,?,?,?,?,?,?,?)');
|
||||||
$ch->execute(['factory-a', 'local', DEMO_LABEL . '1공장 경광등',
|
$ch->execute(['factory-a', 'local', '1공장 경광등 (데모)',
|
||||||
json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]),
|
json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]),
|
||||||
'high', 0, 0, 0, $now]);
|
'high', 0, 0, 0, $now]);
|
||||||
// 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다.
|
// 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다.
|
||||||
$ch->execute(['factory-a', 'email', DEMO_LABEL . '안전관리자 메일 (사용 안 함)',
|
$ch->execute(['factory-a', 'email', '안전관리자 메일 (데모 · 사용 안 함)',
|
||||||
json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]);
|
json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]);
|
||||||
$ch->execute(['factory-a', 'email', DEMO_LABEL . '공장장 에스컬레이션 (사용 안 함)',
|
$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)',
|
||||||
json_encode(['to' => ['plant-manager@example.invalid']]), 'critical', 0, 1, 600, $now]);
|
json_encode(['to' => ['plant-manager@example.invalid']]), 'critical', 0, 1, 600, $now]);
|
||||||
|
|
||||||
$risk = $pdo->prepare('INSERT INTO move_risk_controls (site,event_type,hazard,control,severity_rating,likelihood_rating,reviewed_by,reviewed_at,updated_at)
|
$risk = $pdo->prepare('INSERT INTO move_risk_controls (site,event_type,hazard,control,severity_rating,likelihood_rating,reviewed_by,reviewed_at,updated_at)
|
||||||
|
|
@ -293,7 +285,7 @@ foreach ([
|
||||||
['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2],
|
['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2],
|
||||||
['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3],
|
['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3],
|
||||||
] as [$t, $hazard, $control, $sev, $like]) {
|
] as [$t, $hazard, $control, $sev, $like]) {
|
||||||
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, DEMO_ACTOR, $now - 30 * 86400, $now - 30 * 86400]);
|
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, '안전관리자', $now - 30 * 86400, $now - 30 * 86400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —
|
// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —
|
||||||
|
|
|
||||||
|
|
@ -178,57 +178,3 @@ def test_build_script_passes_shellcheck_if_available():
|
||||||
def test_build_script_is_syntactically_valid_bash():
|
def test_build_script_is_syntactically_valid_bash():
|
||||||
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
|
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
|
||||||
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"
|
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2026-08-18 재감사(A11): CLI 도구가 웹에서 닿으면 안 된다.
|
|
||||||
# tools/dispatch_alerts.php 는 메일을 보내고, backup.php 는 DB 전체를 읽는다.
|
|
||||||
# 이들이 www/ 아래에 실리면 인증 없이 호출 가능한 표면이 된다.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TOOLS_DIR = os.path.join(ROOT, "server", "tools")
|
|
||||||
DIST_PRIV = os.path.join(ROOT, "dist", "1_cafe24_upload", "move_detect_app")
|
|
||||||
DIST_WEB = os.path.join(ROOT, "dist", "1_cafe24_upload", "www", "move_detect")
|
|
||||||
|
|
||||||
needs_package = pytest.mark.skipif(
|
|
||||||
not os.path.isdir(DIST_PRIV), reason="cafe24 package not built in this checkout"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_cli_tool_refuses_to_run_over_http():
|
|
||||||
# bootstrap.php 의 SAPI 검사가 유일한 코드 레벨 방어선이다. 도구가 그것을 require
|
|
||||||
# 하지 않으면 그 방어선이 없는 것과 같다.
|
|
||||||
for name in sorted(os.listdir(TOOLS_DIR)):
|
|
||||||
if not name.endswith(".php") or name == "bootstrap.php":
|
|
||||||
continue
|
|
||||||
src = _read(os.path.join(TOOLS_DIR, name))
|
|
||||||
assert "require __DIR__ . '/bootstrap.php'" in src, (
|
|
||||||
f"tools/{name} 가 bootstrap.php 를 require 하지 않습니다 — CLI 전용 보장이 없습니다."
|
|
||||||
)
|
|
||||||
guard = _read(os.path.join(TOOLS_DIR, "bootstrap.php"))
|
|
||||||
assert "PHP_SAPI !== 'cli'" in guard, "bootstrap.php 의 CLI 전용 가드가 사라졌습니다"
|
|
||||||
|
|
||||||
|
|
||||||
@needs_package
|
|
||||||
def test_tools_ship_outside_the_web_root():
|
|
||||||
assert os.path.isdir(os.path.join(DIST_PRIV, "tools")), (
|
|
||||||
"패키지에 tools/ 가 없습니다 — 통보 디스패처와 보존기간 정리가 동봉되지 않았습니다."
|
|
||||||
)
|
|
||||||
assert not os.path.exists(os.path.join(DIST_WEB, "tools")), (
|
|
||||||
"tools/ 가 웹 공개 경로에 실렸습니다. dispatch_alerts.php 는 메일을 보내고 "
|
|
||||||
"backup.php 는 DB 전체를 읽습니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@needs_package
|
|
||||||
def test_packaged_web_root_holds_no_php_beyond_the_front_controller():
|
|
||||||
# 웹 루트의 PHP 는 index.php 와 (배포 후 삭제하는) _preflight.php 뿐이어야 한다.
|
|
||||||
allowed = {"index.php", "_preflight.php"}
|
|
||||||
found = {
|
|
||||||
os.path.relpath(os.path.join(dirpath, f), DIST_WEB)
|
|
||||||
for dirpath, _, files in os.walk(DIST_WEB)
|
|
||||||
for f in files
|
|
||||||
if f.endswith(".php")
|
|
||||||
}
|
|
||||||
unexpected = found - allowed
|
|
||||||
assert not unexpected, f"웹 루트에 예상치 못한 PHP 파일: {sorted(unexpected)}"
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""엣지가 발화할 수 있는 이벤트 유형과 서버가 받아주는 유형이 일치하는지.
|
|
||||||
|
|
||||||
WHY THIS TEST EXISTS
|
|
||||||
`camera_moved` / `camera_obstructed` / `low_light` 를 추가할 때 엣지 감지기, DB CHECK
|
|
||||||
제약, 프런트 라벨은 갱신했는데 **가운데의 ingest 검증기(EventValidator::TYPES)를 빠뜨렸다.**
|
|
||||||
결과는 최악의 형태였다:
|
|
||||||
|
|
||||||
- 엣지는 이벤트를 만들고, 큐에 넣고, 전송한다
|
|
||||||
- 서버는 400 `bad:event_type` 으로 거부한다
|
|
||||||
- edge/queue.py 는 20회 재시도 후 그 행을 버린다
|
|
||||||
- 로그를 뒤지지 않는 한 아무도 모른다 — 감지기는 구현돼 있고, 단위 테스트도 통과하고,
|
|
||||||
장비에서도 돌지만, 대시보드에는 영원히 아무것도 안 뜬다
|
|
||||||
|
|
||||||
파이썬 테스트도 PHP 테스트도 이걸 잡을 수 없다. 둘 다 자기 쪽 절반만 보기 때문이다.
|
|
||||||
그래서 언어 경계를 넘는 이 대조가 필요하다.
|
|
||||||
|
|
||||||
프런트 라벨 누락은 덜 치명적이지만(원시 코드가 그대로 보인다) 같은 원인이므로 함께 잡는다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from edge.events import EventType
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
VALIDATOR = ROOT / "server" / "src" / "Ingest" / "EventValidator.php"
|
|
||||||
SCHEMA = ROOT / "server" / "schema.sql"
|
|
||||||
EVENTS_VIEW = ROOT / "server" / "src" / "View" / "events.php"
|
|
||||||
# 라벨 맵을 들고 있는 프런트 파일. 새 파일이 생기면 여기에 추가한다.
|
|
||||||
LABEL_FILES = [
|
|
||||||
ROOT / "server" / "public" / "assets" / "app.js",
|
|
||||||
ROOT / "server" / "public" / "assets" / "detail.js",
|
|
||||||
ROOT / "server" / "public" / "assets" / "report.js",
|
|
||||||
ROOT / "server" / "public" / "assets" / "compliance.js",
|
|
||||||
ROOT / "server" / "public" / "assets" / "package.js",
|
|
||||||
]
|
|
||||||
|
|
||||||
# 서버가 스스로 발행하는 유형 — 엣지는 이것을 만들지 않는다 (EscalationWatch).
|
|
||||||
SERVER_ONLY = {"unacked_escalation"}
|
|
||||||
|
|
||||||
|
|
||||||
def _edge_types() -> set[str]:
|
|
||||||
return {e.value for e in EventType}
|
|
||||||
|
|
||||||
|
|
||||||
def _quoted(text: str) -> set[str]:
|
|
||||||
"""'foo','bar' 형태의 작은따옴표 리터럴을 전부 뽑는다."""
|
|
||||||
return set(re.findall(r"'([a-z_]+)'", text))
|
|
||||||
|
|
||||||
|
|
||||||
def _validator_types() -> set[str]:
|
|
||||||
src = VALIDATOR.read_text(encoding="utf-8")
|
|
||||||
m = re.search(r"private const TYPES = \[(.*?)\];", src, re.S)
|
|
||||||
assert m, "EventValidator::TYPES 배열을 찾지 못했습니다 (리팩터되었나요?)"
|
|
||||||
return _quoted(m.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
def _schema_types() -> set[str]:
|
|
||||||
src = SCHEMA.read_text(encoding="utf-8")
|
|
||||||
m = re.search(r"chk_events_type CHECK \(event_type IN \((.*?)\)\)", src, re.S)
|
|
||||||
assert m, "schema.sql 의 chk_events_type 를 찾지 못했습니다"
|
|
||||||
return _quoted(m.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_edge_event_type_is_accepted_by_ingest():
|
|
||||||
missing = _edge_types() - _validator_types()
|
|
||||||
assert not missing, (
|
|
||||||
f"엣지가 발화하지만 서버가 400 으로 거부하는 유형: {sorted(missing)}. "
|
|
||||||
"EventValidator::TYPES 에 추가하세요 — 이 상태에서는 감지기가 조용히 무력화됩니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_edge_event_type_passes_the_db_check():
|
|
||||||
missing = _edge_types() - _schema_types()
|
|
||||||
assert not missing, (
|
|
||||||
f"schema.sql 의 chk_events_type 에 없는 유형: {sorted(missing)}. "
|
|
||||||
"검증기를 통과해도 INSERT 가 제약 위반으로 실패합니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validator_and_schema_agree():
|
|
||||||
# 한쪽에만 있는 유형은 둘 중 하나가 갱신되지 않았다는 뜻이다.
|
|
||||||
assert _validator_types() == _schema_types(), (
|
|
||||||
"EventValidator 와 schema.sql 의 유형 집합이 다릅니다: "
|
|
||||||
f"검증기만={sorted(_validator_types() - _schema_types())}, "
|
|
||||||
f"스키마만={sorted(_schema_types() - _validator_types())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_only_types_are_declared_everywhere_too():
|
|
||||||
# 서버가 만드는 유형은 검증기를 거치지 않지만, 목록을 한 곳에 모아두지 않으면
|
|
||||||
# 다음 사람이 "이건 왜 스키마에만 있지?" 로 시작해 지우게 된다.
|
|
||||||
for t in SERVER_ONLY:
|
|
||||||
assert t in _schema_types(), f"{t} 가 schema.sql CHECK 에 없습니다"
|
|
||||||
assert t in _validator_types(), f"{t} 가 EventValidator::TYPES 에 없습니다"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", LABEL_FILES, ids=lambda p: p.name)
|
|
||||||
def test_frontend_label_maps_cover_every_type(path: Path):
|
|
||||||
if not path.is_file():
|
|
||||||
pytest.skip(f"{path.name} 없음")
|
|
||||||
src = path.read_text(encoding="utf-8")
|
|
||||||
# 라벨 맵을 들고 있는 파일만 검사한다 (없으면 그 파일은 유형을 표시하지 않는다).
|
|
||||||
if "TYPE_LABELS" not in src:
|
|
||||||
pytest.skip(f"{path.name} 에는 TYPE_LABELS 가 없습니다")
|
|
||||||
m = re.search(r"TYPE_LABELS = \{(.*?)\n \};", src, re.S)
|
|
||||||
assert m, f"{path.name} 의 TYPE_LABELS 를 파싱하지 못했습니다"
|
|
||||||
labelled = set(re.findall(r"([a-z_]+)\s*:", m.group(1)))
|
|
||||||
missing = (_edge_types() | SERVER_ONLY) - labelled
|
|
||||||
assert not missing, (
|
|
||||||
f"{path.name} 의 TYPE_LABELS 에 없는 유형: {sorted(missing)} — "
|
|
||||||
"화면에 원시 코드가 그대로 표시됩니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_type_filter_offers_every_type():
|
|
||||||
src = EVENTS_VIEW.read_text(encoding="utf-8")
|
|
||||||
offered = set(re.findall(r'<option value="([a-z_]+)"', src))
|
|
||||||
missing = (_edge_types() | SERVER_ONLY) - offered
|
|
||||||
assert not missing, (
|
|
||||||
f"이벤트 유형 필터에서 고를 수 없는 유형: {sorted(missing)} — "
|
|
||||||
"그 유형만 보려는 조사자가 걸러낼 방법이 없습니다."
|
|
||||||
)
|
|
||||||
Loading…
Reference in a new issue