-- 안전 플랫폼 확장 — 통보·억제·증거무결성·운영지표. -- -- 이 마이그레이션 하나로 다음 기능의 저장소를 연다. 기능별로 파일을 쪼개지 않은 이유는 -- 서로를 참조하기 때문이다(억제 규칙은 통보를 막고, 통보 실패는 감사로그에 남고, 감사로그는 -- 해시체인으로 봉인된다). 부분 적용된 중간 상태가 더 위험하다. -- -- 1. move_audit_log 해시체인 — 증거 무결성 (schema.sql 이 "P3 로 연기"라 적어둔 항목) -- 2. move_suppressions — 정비/작업허가 중 알람 억제 (사유·기간·감사 필수) -- 3. move_alert_channels/_log — 통보 경로와 전달 이력 (지금까지 통보 수단이 전무했다) -- 4. move_shift_handovers — 교대 인수인계 기록 -- 5. move_risk_controls — 감지 유형 ↔ 위험요인·대책 (위험성평가 연계) -- 6. move_device_metrics — 엣지 성능 시계열 (지금은 순간값만 있어 열화를 못 본다) -- 7. move_thresholds 감사 컬럼 — 편집 UI 가 생기므로 누가 언제 바꿨는지 남긴다 -- 8. move_actions.event_id — 어떤 사건이 그 조치의 계기였는지 -- 9. move_events CHECK 확장 — 신규 감지 유형 -- -- 적용 조건: 2026-08-08-actions-site.sql 이후. -- --------------------------------------------------------------------------- -- 1. 감사로그 해시체인 -- 각 행은 직전 행의 entry_hash 를 prev_hash 로 물고, 자신의 내용+prev_hash 를 sha256 한 -- 값을 entry_hash 로 갖는다. 중간 행을 지우거나 고치면 그 이후 전 구간의 사슬이 끊긴다. -- (삭제·수정을 "막는" 것이 아니라 "숨길 수 없게" 만드는 것이 목적이다. append-only 권한을 -- DB 레벨에서 강제할 수 없는 공유호스팅에서는 이것이 실질적인 방어선이다.) -- 기존 행은 NULL 로 남는다 — 체인 이전 구간임을 검증 도구가 그대로 보고한다. ALTER TABLE move_audit_log ADD COLUMN prev_hash CHAR(64) NULL AFTER ts, ADD COLUMN entry_hash CHAR(64) NULL AFTER prev_hash, ADD KEY ix_audit_log_entry_hash (entry_hash); -- --------------------------------------------------------------------------- -- 2. 알람 억제 (정비 모드 / 작업허가) -- 오탐의 최대 원인은 "계획된 작업"이다. 정비 중 제한구역 진입이 전부 알람이면 현장은 -- 시스템을 끄고, 그 다음 진짜 사고를 놓친다. 억제는 감지를 멈추지 않는다 — 이벤트는 그대로 -- 적재되고 통보만 막힌다. 사후 조사에서 "그때 무슨 일이 있었나"가 사라지면 안 되기 때문이다. CREATE TABLE move_suppressions ( suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT, scope_type VARCHAR(16) NOT NULL, -- device | camera | zone scope_id VARCHAR(64) NOT NULL, -- 해당 스코프의 식별자 site VARCHAR(64) NULL, -- 접근제어 스코프 reason VARCHAR(500) NOT NULL, -- 필수: 사유 없는 억제는 허용하지 않는다 starts_at BIGINT UNSIGNED NOT NULL, ends_at BIGINT UNSIGNED NOT NULL, -- 무기한 억제 없음. 반드시 만료된다. created_by VARCHAR(190) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, revoked_by VARCHAR(190) NULL, -- 조기 해제 revoked_at BIGINT UNSIGNED NULL, PRIMARY KEY (suppression_id), KEY ix_suppressions_window (ends_at, starts_at), KEY ix_suppressions_scope (scope_type, scope_id), KEY ix_suppressions_site (site), CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- 3. 통보 경로와 전달 이력 -- 폐쇄망이라 FCM/Slack 을 쓸 수 없다. 사내에서 실제로 닿는 경로만 둔다: -- local — 엣지 노드의 경광등/부저 (GPIO). 인터넷이 필요 없는 유일한 경로이자 1순위. -- email — 사내 SMTP 릴레이 -- webhook— 사내 시스템(그룹웨어·문자게이트웨이) HTTP 엔드포인트 -- config_json 은 경로별 설정(수신자, URL, GPIO 핀 등). 비밀번호류는 넣지 않는다 — -- SMTP 인증정보는 config.php 에 두고 여기서는 참조만 한다. CREATE TABLE move_alert_channels ( channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, kind VARCHAR(16) NOT NULL, -- local | email | webhook name VARCHAR(190) NOT NULL, config_json JSON NOT NULL, min_severity VARCHAR(16) NOT NULL DEFAULT 'high', enabled TINYINT(1) NOT NULL DEFAULT 1, -- 에스컬레이션 단계. 0 = 즉시. 1 이상이면 escalate_after_s 만큼 미확인일 때만 발송한다. step INT UNSIGNED NOT NULL DEFAULT 0, escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (channel_id), KEY ix_alert_channels_site (site, enabled), CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')), CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 전달 이력. (event_id, channel_id) UNIQUE 가 중복 발송을 막는 유일한 방어선이다 — -- 디스패처는 크론으로 돌고 겹쳐 실행될 수 있다. CREATE TABLE move_alert_log ( alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_id INT UNSIGNED NOT NULL, channel_id INT UNSIGNED NOT NULL, status VARCHAR(16) NOT NULL, -- pending | sent | failed | suppressed attempts INT UNSIGNED NOT NULL DEFAULT 0, last_error VARCHAR(500) NULL, queued_at BIGINT UNSIGNED NOT NULL, sent_at BIGINT UNSIGNED NULL, PRIMARY KEY (alert_id), UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id), KEY ix_alert_log_status (status, queued_at), CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')), CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- 4. 교대 인수인계 -- 24시간 브리프는 있으나 교대 경계와 책임 이관이 없었다. 인수자가 "무엇을 넘겨받았는지"를 -- 명시적으로 확인한 기록이 남아야 미처리 이벤트가 교대 사이에서 증발하지 않는다. CREATE TABLE move_shift_handovers ( handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, from_user VARCHAR(190) NULL, -- 최초 교대는 인계자가 없다 to_user VARCHAR(190) NOT NULL, period_start BIGINT UNSIGNED NOT NULL, -- 인계 대상 구간 period_end BIGINT UNSIGNED NOT NULL, unacked_count INT UNSIGNED NOT NULL, -- 인계 시점 스냅샷 (사후 재계산과 대조 가능) severe_count INT UNSIGNED NOT NULL, note VARCHAR(1000) NULL, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (handover_id), KEY ix_handovers_site_created (site, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- 5. 위험성평가 연계 -- 감지 유형 하나하나가 위험요인 하나에 대응한다. 이 표가 있어야 "이 시스템이 무엇을 -- 통제하고 있는가"를 서식으로 뽑을 수 있고, 개선조치가 어느 위험요인을 낮췄는지 이어진다. CREATE TABLE move_risk_controls ( control_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, event_type VARCHAR(32) NOT NULL, -- move_events.event_type 와 같은 코드 hazard VARCHAR(300) NOT NULL, -- 위험요인 control VARCHAR(500) NOT NULL, -- 현재 통제 대책 severity_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (강도) likelihood_rating TINYINT UNSIGNED NOT NULL, -- 1~5 (빈도) reviewed_by VARCHAR(190) NULL, reviewed_at BIGINT UNSIGNED NULL, updated_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (control_id), UNIQUE KEY uq_risk_controls_site_type (site, event_type), CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5), CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- 6. 엣지 성능 시계열 -- move_devices 는 마지막 값만 덮어쓴다. "어제부터 추론 지연이 두 배가 됐다"를 볼 수 없어 -- 서서히 죽는 노드를 놓친다. 하트비트마다 한 행씩 append 하고 보존기간으로 잘라낸다. CREATE TABLE move_device_metrics ( metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL, ts BIGINT UNSIGNED NOT NULL, fps DOUBLE NULL, temp DOUBLE NULL, throttle TINYINT(1) NULL, queue_pending INT UNSIGNED NULL, cameras_total INT UNSIGNED NULL, cameras_erroring INT UNSIGNED NULL, errors INT UNSIGNED NULL, PRIMARY KEY (metric_id), KEY ix_device_metrics_device_ts (device_id, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- 7. 임계값 감사 컬럼 (편집 UI 도입) -- 지금까지 이 표는 phpMyAdmin 으로 직접 고치는 것이 유일한 방법이었다(스키마 주석 참조). -- 화면이 생기면 누가 언제 바꿨는지가 사고 조사에서 반드시 필요해진다. ALTER TABLE move_thresholds ADD COLUMN updated_by VARCHAR(190) NULL, ADD COLUMN updated_at BIGINT UNSIGNED NULL; -- --------------------------------------------------------------------------- -- 8. 조치 ↔ 계기 사건 ALTER TABLE move_actions ADD COLUMN event_id INT UNSIGNED NULL AFTER site, ADD KEY ix_actions_event (event_id); -- --------------------------------------------------------------------------- -- 9. 신규 감지 유형 -- camera_moved/obstructed 는 존 기반 판정의 조용한 실패를 드러내는 유형이고, -- unacked_escalation 은 "아무도 확인하지 않았다"는 사실 자체를 사건으로 만든다. -- ppe_missing/proximity/fire_smoke 는 모델 확보 후 활성화되는 자리다 (엣지에서 아직 발화하지 않음). 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', 'zone_restricted','pinch','monitoring_degraded','device_fault', 'lone_worker','overcapacity','overstay','near_fall','ergo_report', 'camera_moved','camera_obstructed','low_light','unacked_escalation', 'ppe_missing','proximity','fire_smoke' )); -- 검증: -- SELECT COUNT(*) FROM move_audit_log WHERE entry_hash IS NULL; -- 체인 이전 구간 -- php server/tools/verify_audit_chain.php -- 사슬 무결성 -- SHOW COLUMNS FROM move_thresholds LIKE 'updated_%';