diff --git a/CLAUDE.md b/CLAUDE.md
index 7560625..87beba4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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)
diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md
index b6b5cf1..8f0c26b 100644
--- a/docs/delivery-audit.md
+++ b/docs/delivery-audit.md
@@ -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)
diff --git a/server/config.example.php b/server/config.example.php
index 58840c5..1cedf5b 100644
--- a/server/config.example.php
+++ b/server/config.example.php
@@ -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 가 정리).
diff --git a/server/deploy/config.template.php b/server/deploy/config.template.php
index 47890d2..38940e9 100644
--- a/server/deploy/config.template.php
+++ b/server/deploy/config.template.php
@@ -41,6 +41,10 @@ return [
// 위급/높음 이벤트를 이 시간(초) 동안 아무도 확인하지 않으면 '미확인 경보' 이벤트를
// 새로 만들어 상위 담당자에게 통보합니다. 300 = 5분.
'escalate_after_seconds' => 300,
+ // 알림 메일·웹훅에 찍히는 시각의 기준 시간대입니다. 대시보드 화면은 보는 사람의 PC
+ // 시각을 쓰지만 알림은 서버가 만들기 때문에, 이 값이 틀리면 화면과 메일의 시각이
+ // 서로 다르게 나옵니다. 한국 현장은 그대로 두세요.
+ 'timezone' => 'Asia/Seoul',
// ---- 5. 용량 관리 (그대로 두세요) -----------------------------------
// 장비 성능 추이 기록 보존일. 장비 1대당 하루 약 2,880행이 쌓입니다.
diff --git a/server/migrations/2026-08-09-safety-platform.sql b/server/migrations/2026-08-09-safety-platform.sql
index 6876839..df5a13e 100644
--- a/server/migrations/2026-08-09-safety-platform.sql
+++ b/server/migrations/2026-08-09-safety-platform.sql
@@ -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';
diff --git a/server/public/assets/app.js b/server/public/assets/app.js
index 5e893f4..0d7084c 100644
--- a/server/public/assets/app.js
+++ b/server/public/assets/app.js
@@ -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();
});
});
diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js
index f4967a8..31dc7ca 100644
--- a/server/public/assets/detail.js
+++ b/server/public/assets/detail.js
@@ -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
diff --git a/server/public/assets/report.js b/server/public/assets/report.js
index baecddb..e5484da 100644
--- a/server/public/assets/report.js
+++ b/server/public/assets/report.js
@@ -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: '화재·연기'
};
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
diff --git a/server/src/Alert/AlertDispatcher.php b/server/src/Alert/AlertDispatcher.php
index 42c385d..60df1bd 100644
--- a/server/src/Alert/AlertDispatcher.php
+++ b/server/src/Alert/AlertDispatcher.php
@@ -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);
diff --git a/server/src/Alert/Channels.php b/server/src/Alert/Channels.php
index 9c4df9d..6e725c5 100644
--- a/server/src/Alert/Channels.php
+++ b/server/src/Alert/Channels.php
@@ -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", [
"작업장 안전 감지 알림",
diff --git a/server/src/Config.php b/server/src/Config.php
index a851400..bc41556 100644
--- a/server/src/Config.php
+++ b/server/src/Config.php
@@ -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
diff --git a/server/src/Dashboard/AdminConfigController.php b/server/src/Dashboard/AdminConfigController.php
index 717bc94..d7e688d 100644
--- a/server/src/Dashboard/AdminConfigController.php
+++ b/server/src/Dashboard/AdminConfigController.php
@@ -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),
diff --git a/server/src/Ingest/EventValidator.php b/server/src/Ingest/EventValidator.php
index 2aa3e90..75e31f7 100644
--- a/server/src/Ingest/EventValidator.php
+++ b/server/src/Ingest/EventValidator.php
@@ -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'];
diff --git a/server/src/Repository/AlertRepository.php b/server/src/Repository/AlertRepository.php
index b223eed..328fa3d 100644
--- a/server/src/Repository/AlertRepository.php
+++ b/server/src/Repository/AlertRepository.php
@@ -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;
}
diff --git a/server/src/Repository/Pdo/PdoAlertRepository.php b/server/src/Repository/Pdo/PdoAlertRepository.php
index 5f58faa..c72b1a1 100644
--- a/server/src/Repository/Pdo/PdoAlertRepository.php
+++ b/server/src/Repository/Pdo/PdoAlertRepository.php
@@ -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
diff --git a/server/src/View/events.php b/server/src/View/events.php
index 1f1ed3b..7284965 100644
--- a/server/src/View/events.php
+++ b/server/src/View/events.php
@@ -43,6 +43,16 @@ render_header('이벤트');
+
+
+
+
+
+
+
+
+
diff --git a/server/tests/AlertDispatchTest.php b/server/tests/AlertDispatchTest.php
index 866bc1f..11e49c4 100644
--- a/server/tests/AlertDispatchTest.php
+++ b/server/tests/AlertDispatchTest.php
@@ -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');
diff --git a/server/tests/SafetyConfigTest.php b/server/tests/SafetyConfigTest.php
index 2043287..4b8bdcd 100644
--- a/server/tests/SafetyConfigTest.php
+++ b/server/tests/SafetyConfigTest.php
@@ -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();
diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php
index 6297fe8..329ee9d 100644
--- a/server/tests/Support/InMemoryRepositories.php
+++ b/server/tests/Support/InMemoryRepositories.php
@@ -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;
diff --git a/server/tools/backup.php b/server/tools/backup.php
index 35f4dc5..6a00411 100644
--- a/server/tools/backup.php
+++ b/server/tools/backup.php
@@ -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');
diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php
index 8d8023e..c5e6fb5 100644
--- a/server/tools/dispatch_alerts.php
+++ b/server/tools/dispatch_alerts.php
@@ -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);
diff --git a/server/tools/maintain.php b/server/tools/maintain.php
index 230dbcd..58159f8 100644
--- a/server/tools/maintain.php
+++ b/server/tools/maintain.php
@@ -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 < ?');
diff --git a/server/tools/seed_demo.php b/server/tools/seed_demo.php
index dbea241..76e9cb5 100644
--- a/server/tools/seed_demo.php
+++ b/server/tools/seed_demo.php
@@ -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 는 오후부터 스로틀이 걸리며 프레임률이 흘러내린다 —
diff --git a/tests/test_cafe24_package.py b/tests/test_cafe24_package.py
index d60eac5..14540ee 100644
--- a/tests/test_cafe24_package.py
+++ b/tests/test_cafe24_package.py
@@ -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)}"
diff --git a/tests/test_event_type_parity.py b/tests/test_event_type_parity.py
new file mode 100644
index 0000000..0530f79
--- /dev/null
+++ b/tests/test_event_type_parity.py
@@ -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'