fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.
CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
(tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
(/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.
HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.
MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.
LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.
수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.
부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.
검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
This commit is contained in:
parent
5c7c65351d
commit
7daee0b372
25 changed files with 625 additions and 39 deletions
|
|
@ -7,7 +7,7 @@ Pi5+Hailo-8 엣지(Python `edge/`) → HMAC POST → Cafe24 PHP+MySQL 대시보
|
|||
|
||||
- 위치 참조: `docs/PROJECT_MAP.md` 먼저 읽기 (전체 재스캔 금지). 변경 시 MAP 갱신.
|
||||
- 작업 기록: 단락 종료마다 `docs/INDEX.md`에 결과만 추가.
|
||||
- 감사 상태: `docs/delivery-audit.md` (43/43 — 실장비 OFF-HW 검증만 잔여).
|
||||
- 감사 상태: `docs/delivery-audit.md` (1차 43/43 + 재감사 12/12 — **적합**, 실장비 OFF-HW 검증만 잔여).
|
||||
|
||||
## 검증 (verify)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# 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
|
||||
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.
|
||||
|
|
@ -258,6 +262,109 @@ 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
|
||||
|
||||
(updated as each item lands — newest first)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ return [
|
|||
'public_base_url' => '', // 예: 'https://example.com/move_detect'
|
||||
// 위급/높음 이벤트가 이 시간(초) 동안 미확인이면 unacked_escalation 이벤트를 발행한다.
|
||||
'escalate_after_seconds' => 300,
|
||||
// 통보 메시지의 시각 표기 기준. 대시보드는 브라우저 시각을 쓰지만 알림 메일은 PHP 가
|
||||
// 만들고, 공유호스팅은 date.timezone 이 UTC 인 경우가 흔하다 — 콘솔이 13:01 로 보여준
|
||||
// 넘어짐을 메일이 04:01 로 적으면 응급 대응에서 그대로 사고가 된다.
|
||||
'timezone' => 'Asia/Seoul',
|
||||
|
||||
// --- 보존 / 용량 ------------------------------------------------------------------
|
||||
// 하트비트 시계열 보존일. 장치당 하루 약 2,880행이 쌓인다 (tools/prune.php 가 정리).
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ return [
|
|||
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
|
||||
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
|
||||
'escalate_after_seconds' => 300,
|
||||
// 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC
|
||||
// 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
|
||||
// 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
|
||||
'timezone' => 'Asia/Seoul',
|
||||
|
||||
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
|
||||
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
|
||||
|
|
|
|||
|
|
@ -170,12 +170,35 @@ ALTER TABLE move_actions
|
|||
ADD KEY ix_actions_event (event_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. 신규 감지 유형
|
||||
-- 9. 신규 감지 유형 ★ 이 블록만 문법이 DB 엔진마다 다릅니다 — 아래 안내를 읽으세요 ★
|
||||
--
|
||||
-- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고,
|
||||
-- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다.
|
||||
-- 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
|
||||
DROP CHECK chk_events_type;
|
||||
|
||||
ALTER TABLE move_events
|
||||
ADD CONSTRAINT chk_events_type CHECK (event_type IN (
|
||||
'fall','no_motion','immobility','ghost','zone_warning',
|
||||
|
|
@ -187,5 +210,9 @@ ALTER TABLE move_events
|
|||
|
||||
-- 검증:
|
||||
-- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간
|
||||
-- php server/tools/verify_audit_chain.php -- 사슬 무결성
|
||||
-- php tools/verify_audit_chain.php -- 사슬 무결성
|
||||
-- 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,7 +29,12 @@
|
|||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||
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 = {
|
||||
|
|
@ -1069,17 +1074,36 @@
|
|||
disarmBulk();
|
||||
bulkBtn.disabled = true;
|
||||
var done = 0;
|
||||
Promise.all(targets.map(function (ev) {
|
||||
var queue = targets.slice();
|
||||
// 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', {
|
||||
method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' }
|
||||
}).then(function (res) {
|
||||
if (res.ok) { patchEventLocally(ev.event_id, false); done++; }
|
||||
}).catch(function () {});
|
||||
})).then(function () {
|
||||
})
|
||||
.then(function (res) { if (res.ok) { patchEventLocally(ev.event_id, false); done++; } })
|
||||
.catch(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;
|
||||
lastListSig = null;
|
||||
renderList();
|
||||
showToast('일괄 확인 ' + done + '건 (위급은 제외되었습니다)');
|
||||
showToast('일괄 확인 ' + done + '건 / ' + targets.length + '건 (위급은 제외되었습니다)');
|
||||
loadEvents();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,12 @@
|
|||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||
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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@
|
|||
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
|
||||
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
|
||||
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,6 +94,12 @@ final class AlertDispatcher
|
|||
// 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.
|
||||
$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) {
|
||||
$stats['considered']++;
|
||||
|
|
@ -105,8 +111,12 @@ final class AlertDispatcher
|
|||
foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) {
|
||||
$channelId = (int)$delivery['channel']['channel_id'];
|
||||
$eventId = (int)$event['event_id'];
|
||||
// claim() is the duplicate guard. Losing it means another (possibly overlapping)
|
||||
// dispatcher run owns this delivery — skip, do not send.
|
||||
// Cheap pre-filter (see $alreadyClaimed) — skips the overwhelming majority of
|
||||
// pairs on every run after the first.
|
||||
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 ($delivery['suppressed']) {
|
||||
$this->alerts->markSuppressed($eventId, $channelId, $now);
|
||||
|
|
|
|||
|
|
@ -39,8 +39,30 @@ final class Channels
|
|||
'critical' => '위급', 'high' => '높음', 'warning' => '경고', 'info' => '정보',
|
||||
];
|
||||
|
||||
/** @param string $baseUrl absolute dashboard URL, for the incident link. */
|
||||
public function __construct(private string $baseUrl, private int $timeoutSeconds = 8) {}
|
||||
/**
|
||||
* @param string $baseUrl absolute dashboard URL, for the incident link.
|
||||
* @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
|
||||
{
|
||||
|
|
@ -67,7 +89,7 @@ final class Channels
|
|||
{
|
||||
$type = self::TYPE_LABELS[$event['event_type']] ?? (string)$event['event_type'];
|
||||
$sev = self::SEVERITY_LABELS[$event['severity']] ?? (string)$event['severity'];
|
||||
$when = date('Y-m-d H:i:s', (int)$event['occurred_at']);
|
||||
$when = $this->stamp((int)$event['occurred_at']);
|
||||
$link = rtrim($this->baseUrl, '/') . '/events/' . (int)$event['event_id'] . '/view';
|
||||
return implode("\n", [
|
||||
"작업장 안전 감지 알림",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,24 @@ final class Config
|
|||
*/
|
||||
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
|
||||
* `unacked_escalation` event of its own (App\Alert\EscalationWatch). Five minutes by
|
||||
|
|
|
|||
|
|
@ -163,12 +163,18 @@ final class AdminConfigController
|
|||
if ($config === null) return Response::json(['error' => 'invalid_config'], 400);
|
||||
|
||||
$id = (int)($b['channel_id'] ?? 0);
|
||||
$site = $this->site(); // new channels belong to the caller's scope
|
||||
if ($id > 0) {
|
||||
$existing = null;
|
||||
foreach ($this->alerts->allChannels($this->site()) as $c) {
|
||||
if ((int)$c['channel_id'] === $id) { $existing = $c; break; }
|
||||
}
|
||||
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.
|
||||
if (($config['token'] ?? null) === '••••••••') {
|
||||
$old = json_decode($existing['config_json'], true);
|
||||
|
|
@ -179,7 +185,7 @@ final class AdminConfigController
|
|||
|
||||
$newId = $this->alerts->saveChannel([
|
||||
'channel_id' => $id > 0 ? $id : null,
|
||||
'site' => $this->site(),
|
||||
'site' => $site,
|
||||
'kind' => $kind,
|
||||
'name' => $name,
|
||||
'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,29 @@ namespace App\Ingest;
|
|||
|
||||
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',
|
||||
'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 REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];
|
||||
|
||||
|
|
|
|||
|
|
@ -31,4 +31,15 @@ interface AlertRepository
|
|||
|
||||
/** Rows still worth retrying (status=failed, attempts under the cap). */
|
||||
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,6 +160,30 @@ final class PdoAlertRepository implements AlertRepository
|
|||
], $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
|
||||
{
|
||||
// MySQL 1062 / SQLSTATE 23000, sqlite 19 / SQLSTATE 23000. Match on the SQLSTATE class
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ render_header('이벤트');
|
|||
<option value="ergo_report">작업자세 부담 리포트</option>
|
||||
<option value="monitoring_degraded">감시 성능 저하</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>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -233,6 +233,30 @@ final class AlertDispatchTest extends TestCase
|
|||
$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
|
||||
{
|
||||
$c = new Channels('https://example.com');
|
||||
|
|
|
|||
|
|
@ -181,6 +181,34 @@ final class SafetyConfigTest extends TestCase
|
|||
$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
|
||||
{
|
||||
$c = $this->admin();
|
||||
|
|
|
|||
|
|
@ -362,6 +362,10 @@ final class InMemorySuppressionRepository implements SuppressionRepository {
|
|||
}
|
||||
final class InMemoryAlertRepository implements AlertRepository {
|
||||
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 channels(?string $s): array {
|
||||
$r = array_values(array_filter($this->chan, fn($c) =>
|
||||
|
|
@ -385,6 +389,7 @@ final class InMemoryAlertRepository implements AlertRepository {
|
|||
}
|
||||
private function key(int $e, int $c): string { return $e . ':' . $c; }
|
||||
public function claim(int $e, int $c, int $now): bool {
|
||||
$this->claimCalls++;
|
||||
if (isset($this->log[$this->key($e, $c)])) return false;
|
||||
$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];
|
||||
|
|
@ -409,6 +414,19 @@ final class InMemoryAlertRepository implements AlertRepository {
|
|||
return array_slice(array_values(array_filter($this->log,
|
||||
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 {
|
||||
public array $rows = []; private int $seq = 0;
|
||||
|
|
|
|||
|
|
@ -34,12 +34,28 @@ if (!is_dir($outDir) && !@mkdir($outDir, 0700, true)) {
|
|||
fwrite(STDERR, "출력 디렉터리를 만들 수 없습니다: $outDir\n");
|
||||
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);
|
||||
if ($real !== false && str_contains(str_replace('\\', '/', $real), '/www/')) {
|
||||
fwrite(STDERR, "거부: 백업 대상이 웹 공개 경로(www/) 안입니다. 이 파일은 사고 기록과 개인정보를 담습니다.\n");
|
||||
exit(2);
|
||||
if ($real !== false) {
|
||||
$segments = array_filter(explode('/', str_replace('\\', '/', $real)), 'strlen');
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stamp = date('Ymd-His');
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ if ($baseUrl === '') {
|
|||
fwrite(STDERR, "config.php 에 public_base_url 이 없습니다. 통보 메시지의 사건 링크가 만들어지지 않습니다.\n");
|
||||
exit(2);
|
||||
}
|
||||
$channels = new Channels($baseUrl);
|
||||
$channels = new Channels($baseUrl, 8, $cfg->timezone());
|
||||
|
||||
// --- 1. 에스컬레이션 발행 -------------------------------------------------------------
|
||||
$watch = new EscalationWatch($events);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ declare(strict_types=1);
|
|||
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository};
|
||||
use App\Repository\Pdo\{PdoEventRepository, PdoSnapshotRepository, PdoDeviceMetricRepository,
|
||||
PdoAlertRepository};
|
||||
|
||||
[$cfg, $pdo] = tool_boot();
|
||||
$args = tool_args($argv);
|
||||
|
|
@ -62,6 +63,21 @@ 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 ---------------------------------------------------------------------------
|
||||
if ($dry) {
|
||||
$st = $pdo->prepare('SELECT COUNT(*) FROM move_nonces WHERE expires_at < ?');
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ require __DIR__ . '/bootstrap.php';
|
|||
[$cfg, $pdo] = tool_boot();
|
||||
$args = tool_args($argv);
|
||||
|
||||
// Demo rows are marked in the one field no ingest path writes: a device_id prefix. That makes
|
||||
// --purge exact instead of "delete everything and hope".
|
||||
const DEMO_PREFIX = 'demo-';
|
||||
// Demo rows carry a marker in a field no real path ever writes, so --purge can target exactly
|
||||
// those rows and nothing else. This is not cosmetic: a purge that matches on a plausible real
|
||||
// value (an operator name, a site name) will one day be run against a production database and
|
||||
// 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)));
|
||||
$force = isset($args['force']);
|
||||
|
|
@ -62,11 +66,15 @@ function purge(PDO $pdo, string $snapshotDir): void
|
|||
$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_device_metrics WHERE device_id LIKE ?')->execute([$like]);
|
||||
$pdo->prepare("DELETE FROM move_actions WHERE created_by = 'demo-seed'")->execute();
|
||||
// 데모 채널/위험요인은 이름·검토자에 표식이 있다. 실제 운영 행을 지우지 않도록
|
||||
// LIKE 로 좁힌다 — 시연용 DB 와 운영 DB 가 같은 호스트에 있는 경우가 실제로 있다.
|
||||
$pdo->prepare("DELETE FROM move_alert_channels WHERE name LIKE '%(데모%'")->execute();
|
||||
$pdo->prepare("DELETE FROM move_risk_controls WHERE reviewed_by = '안전관리자' AND site = 'factory-a'")->execute();
|
||||
$pdo->prepare("DELETE FROM move_actions WHERE created_by = ?")->execute([DEMO_ACTOR]);
|
||||
// 데모 행은 반드시 DEMO_ACTOR / DEMO_PREFIX 표식으로만 식별한다.
|
||||
//
|
||||
// 감사 지적(A2): 예전에는 위험요인을 `reviewed_by='안전관리자' AND site='factory-a'` 로
|
||||
// 지웠다. '안전관리자'는 이 제품의 기본 관리자 계정명이고 'factory-a'는 흔한 현장명이라,
|
||||
// 운영 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)");
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +267,7 @@ foreach ([
|
|||
['cam-wh-02', '자재창고 통로 폭 확대 · 안전선 재도색', 12],
|
||||
['cam-press-02', '협착 구역 광전센서 추가 · 방호덮개 교체', 5],
|
||||
] as [$c, $note, $ago]) {
|
||||
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, 'demo-seed', $now - $ago * 86400]);
|
||||
$act->execute([$c, 'factory-a', $note, $now - $ago * 86400, DEMO_ACTOR, $now - $ago * 86400]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 통보 · 규정 · 성능
|
||||
|
|
@ -267,13 +275,13 @@ foreach ([
|
|||
// 다만 어떤 것도 실제로 발송되지 않도록, 채널은 전부 비활성으로 넣는다.
|
||||
$ch = $pdo->prepare('INSERT INTO move_alert_channels (site,kind,name,config_json,min_severity,enabled,step,escalate_after_s,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)');
|
||||
$ch->execute(['factory-a', 'local', '1공장 경광등 (데모)',
|
||||
$ch->execute(['factory-a', 'local', DEMO_LABEL . '1공장 경광등',
|
||||
json_encode(['gpio_pin' => 17, 'mode' => 'latch', 'hold_s' => 45, 'active_high' => false]),
|
||||
'high', 0, 0, 0, $now]);
|
||||
// 메일/웹훅은 enabled=0. 데모 DB 로 실수로 크론을 돌려도 아무 데도 나가지 않는다.
|
||||
$ch->execute(['factory-a', 'email', '안전관리자 메일 (데모 · 사용 안 함)',
|
||||
$ch->execute(['factory-a', 'email', DEMO_LABEL . '안전관리자 메일 (사용 안 함)',
|
||||
json_encode(['to' => ['safety@example.invalid']]), 'critical', 0, 0, 0, $now]);
|
||||
$ch->execute(['factory-a', 'email', '공장장 에스컬레이션 (데모 · 사용 안 함)',
|
||||
$ch->execute(['factory-a', 'email', DEMO_LABEL . '공장장 에스컬레이션 (사용 안 함)',
|
||||
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)
|
||||
|
|
@ -285,7 +293,7 @@ foreach ([
|
|||
['lone_worker', '자재창고 야간 단독 작업 중 사고 시 발견 지연', '2인 1조 편성 · 단독 작업 감지', 4, 2],
|
||||
['overstay', '도장부스 장시간 체류로 인한 유기용제 노출', '체류 상한 30분 · 국소배기 점검', 3, 3],
|
||||
] as [$t, $hazard, $control, $sev, $like]) {
|
||||
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, '안전관리자', $now - 30 * 86400, $now - 30 * 86400]);
|
||||
$risk->execute(['factory-a', $t, $hazard, $control, $sev, $like, DEMO_ACTOR, $now - 30 * 86400, $now - 30 * 86400]);
|
||||
}
|
||||
|
||||
// 성능 시계열: 24시간치. pi-a2 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —
|
||||
|
|
|
|||
|
|
@ -178,3 +178,57 @@ def test_build_script_passes_shellcheck_if_available():
|
|||
def test_build_script_is_syntactically_valid_bash():
|
||||
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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)}"
|
||||
|
|
|
|||
125
tests/test_event_type_parity.py
Normal file
125
tests/test_event_type_parity.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""엣지가 발화할 수 있는 이벤트 유형과 서버가 받아주는 유형이 일치하는지.
|
||||
|
||||
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