AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는 자세추정·구역·트랙 이력만으로 구현했다. ■ 추락 (fall_from_height) 잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못 잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로 (_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라 아무것도 안 걸렸다. 판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은 내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로 재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도 내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고 있어 별도 버퍼가 없다. 발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이 확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로 낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과 등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마 변경이 없고, 구역이 없어도 하강 신호만으로 동작한다. ■ 동시 다발 쓰러짐 (multi_down) 같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식· 가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간 사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서 "공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로 유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진 것과 같다). 서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이 필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의 핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진 것은 한 명으로 센다. ■ 구역 프리셋 6종 + max_workers:0 수정 밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데 화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다. 안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다. 그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로 꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란 (min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다. ■ 검증 pytest 322 passed / phpunit 263 passed. 신규 테스트 30건. 이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum · EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고 기존 설치용 마이그레이션을 추가했다. ■ Pi 번들 1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다. 낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로 납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
437 lines
25 KiB
SQL
437 lines
25 KiB
SQL
-- cloud/schema.sql
|
|
-- MySQL 8.0+ DDL for the P1-B cloud ingest + dashboard subset
|
|
-- (backing store for App\Repository\Pdo\* -- see cloud/src/Repository/Pdo/).
|
|
--
|
|
-- Scope: this is the P1-B subset of the full design-spec schema (design doc §7.4).
|
|
-- Tables/columns that belong to later phases are intentionally OMITTED here (YAGNI):
|
|
-- sites, escalations, oncall, compliance, retention_policy, audit hash-chaining
|
|
-- (prev_hash/hash on audit_log), and events.disposition/is_test/legal_hold/lifecycle_state.
|
|
-- See docs/superpowers/specs/2026-07-11-workplace-safety-ai-cctv-design.md §7.4 and the
|
|
-- Task 16 brief self-review for the P1-B / P2 / P3 split.
|
|
--
|
|
-- sqlite compatibility notes (cloud/tests/PdoIntegrationTest.php uses its own minimal
|
|
-- sqlite-flavored DDL for the tables it round-trips -- this file targets MySQL only,
|
|
-- these notes exist to explain the mechanical translation if that ever needs to grow):
|
|
-- * `INT ... AUTO_INCREMENT` -> sqlite: `INTEGER PRIMARY KEY AUTOINCREMENT` (must be the
|
|
-- table's sole INTEGER PRIMARY KEY column)
|
|
-- * `ENGINE=InnoDB` -> sqlite: omit (no storage engines)
|
|
-- * `DEFAULT CHARSET=...` -> sqlite: omit
|
|
-- * `JSON` column type -> sqlite: `TEXT` (no native JSON type; app always
|
|
-- reads/writes JSON-encoded strings either way)
|
|
-- * `DEFAULT (UNIX_TIMESTAMP())` -> sqlite: `DEFAULT (strftime('%s','now'))`
|
|
-- * `INSERT ... ON DUPLICATE KEY UPDATE` (rate_limits upsert) -> sqlite:
|
|
-- `INSERT ... ON CONFLICT(rate_key, window_start)
|
|
-- DO UPDATE SET count = count + 1`
|
|
-- * `CHECK (...)` on enum-like columns -> sqlite supports CHECK natively, no change
|
|
-- * `TINYINT(1)` -> sqlite: `INTEGER` (0/1)
|
|
-- * `BIGINT UNSIGNED` -> sqlite: `INTEGER` (sqlite ints are always 64-bit signed)
|
|
--
|
|
-- All app-level SQL against this schema uses prepared statements with `?` placeholders --
|
|
-- no string interpolation of values anywhere in App\Repository\Pdo\*.
|
|
|
|
SET NAMES utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- users: dashboard accounts (auth + RBAC + brute-force lockout).
|
|
-- No PdoUserRepository::create() exists in P1-B -- the first admin account is
|
|
-- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_users (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
username VARCHAR(190) NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash()
|
|
role VARCHAR(32) NOT NULL, -- app-whitelisted: admin|operator|viewer (App\Auth\Rbac)
|
|
site_scope VARCHAR(190) NULL,
|
|
failed_attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
|
locked_until BIGINT UNSIGNED NOT NULL DEFAULT 0, -- unix timestamp; 0 = not locked
|
|
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_users_username (username),
|
|
CONSTRAINT chk_users_role CHECK (role IN ('admin','operator','viewer'))
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- devices: one row per Pi/edge device; holds the per-device HMAC ingest secret.
|
|
-- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin
|
|
-- (per-device secret issuance) before the device is allowed to sync/ingest.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_devices (
|
|
device_id VARCHAR(64) NOT NULL,
|
|
name VARCHAR(190) NULL,
|
|
site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned)
|
|
secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log
|
|
config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config
|
|
last_fps DOUBLE NULL,
|
|
last_temp DOUBLE NULL,
|
|
last_throttle TINYINT(1) NULL,
|
|
reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat
|
|
last_heartbeat_at BIGINT UNSIGNED NULL,
|
|
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
|
PRIMARY KEY (device_id),
|
|
KEY ix_devices_site (site)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- cameras: per-device camera inventory (design doc §7.4). No dedicated
|
|
-- PdoCameraRepository exists in P1-B -- zones/events reference camera_id
|
|
-- directly as a free-form string. Kept for documentation/future per-camera
|
|
-- config and to avoid a breaking migration when that repository is added.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_cameras (
|
|
camera_id VARCHAR(64) NOT NULL,
|
|
device_id VARCHAR(64) NOT NULL,
|
|
name VARCHAR(190) NULL,
|
|
role VARCHAR(32) NULL, -- app-whitelisted: fall_critical|zone|low
|
|
created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()),
|
|
PRIMARY KEY (camera_id),
|
|
KEY ix_cameras_device (device_id),
|
|
CONSTRAINT chk_cameras_role CHECK (role IS NULL OR role IN ('fall_critical','zone','low'))
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- zones: polygon regions per camera, edited via the zone-config dashboard.
|
|
-- No FK to cameras/devices: zones may legitimately be created before a
|
|
-- `cameras` row exists for a given camera_id (no camera-registration flow in
|
|
-- P1-B), so a FK here would spuriously reject valid app writes.
|
|
-- ---------------------------------------------------------------------------
|
|
-- ---------------------------------------------------------------------------
|
|
-- Improvement action log (개선 조치): a site fix ("미끄럼 매트 설치") recorded against a
|
|
-- camera; the report compares detection density before/after to prove the fix worked.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_actions (
|
|
action_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
camera_id VARCHAR(64) NOT NULL,
|
|
site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert
|
|
event_id INT UNSIGNED NULL, -- 이 조치의 계기가 된 사건 (상세 화면에서 바로 기록할 때)
|
|
note VARCHAR(500) NOT NULL,
|
|
taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s)
|
|
created_by VARCHAR(190) NOT NULL,
|
|
created_at BIGINT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (action_id),
|
|
KEY ix_actions_taken (taken_at),
|
|
KEY ix_actions_site_taken (site, taken_at),
|
|
KEY ix_actions_event (event_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
CREATE TABLE move_zones (
|
|
zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids
|
|
device_id VARCHAR(64) NOT NULL,
|
|
camera_id VARCHAR(64) NOT NULL,
|
|
level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS)
|
|
polygon_json JSON NOT NULL,
|
|
rules_json JSON NULL, -- optional occupancy rules: {"min_workers","max_workers","max_dwell_s"} (ZoneConfigController validates)
|
|
updated_at BIGINT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (zone_id),
|
|
KEY ix_zones_camera (camera_id),
|
|
KEY ix_zones_device (device_id),
|
|
CONSTRAINT chk_zones_level CHECK (level IN ('warning','restricted','pinch'))
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- events: ingested detections. event_type/severity are already validated in
|
|
-- App\Ingest\EventValidator before insert(); the CHECK constraints below are
|
|
-- defense-in-depth only, not the primary validation layer.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_events (
|
|
event_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows
|
|
device_id VARCHAR(64) NOT NULL,
|
|
site VARCHAR(64) NULL, -- denormalized from move_devices.site at ingest; access-control scope
|
|
camera_id VARCHAR(64) NOT NULL,
|
|
event_type VARCHAR(32) NOT NULL,
|
|
severity VARCHAR(16) NOT NULL,
|
|
track_id INT NULL,
|
|
zone_id CHAR(32) NULL,
|
|
occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float)
|
|
duration_s DOUBLE NOT NULL DEFAULT 0,
|
|
metrics_json JSON NULL,
|
|
spatial_x DOUBLE NULL, -- normalized foot/reference point (0..1 of the frame),
|
|
spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events
|
|
acknowledged_by VARCHAR(190) NULL,
|
|
acknowledged_at BIGINT UNSIGNED NULL,
|
|
false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
|
|
false_positive_at BIGINT UNSIGNED NULL,
|
|
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
|
|
PRIMARY KEY (event_id),
|
|
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
|
|
-- but the ACK timed out client-side) collide instead of duplicating. MySQL allows multiple
|
|
-- NULLs in a UNIQUE index, so legacy rows without a uuid do not conflict.
|
|
UNIQUE KEY uq_events_uuid (event_uuid),
|
|
KEY ix_events_occurred_at (occurred_at),
|
|
KEY ix_events_severity_occurred_at (severity, occurred_at),
|
|
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
|
|
KEY ix_events_device (device_id),
|
|
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
|
|
CONSTRAINT chk_events_type CHECK (event_type IN (
|
|
'fall','fall_from_height','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','multi_down',
|
|
-- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다.
|
|
'ppe_missing','proximity','fire_smoke'
|
|
)),
|
|
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- event_snapshots: re-encoded JPEG references. Files live under storage/
|
|
-- (webroot-adjacent, served only via the authenticated SnapshotProxy). FK to
|
|
-- events is safe because IngestController always inserts the parent event row
|
|
-- before calling SnapshotRepository::add() for its snapshots.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_event_snapshots (
|
|
snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
event_id INT UNSIGNED NOT NULL,
|
|
file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = <uuid>.jpg
|
|
content_hash CHAR(64) NOT NULL, -- sha256 of the re-encoded bytes
|
|
created_at BIGINT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (snapshot_id),
|
|
UNIQUE KEY uq_event_snapshots_uuid (file_uuid),
|
|
-- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same
|
|
-- content_hash), so this makes a duplicate/racing store collide instead of writing a second
|
|
-- copy. Lets the dedup path safely re-store missing evidence without duplication.
|
|
UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash),
|
|
KEY ix_event_snapshots_event (event_id),
|
|
KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ?
|
|
CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES move_events (event_id)
|
|
ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- thresholds: per-device (optionally per-camera/zone) tunables synced via
|
|
-- GET /api/config. No PdoThresholdRepository write method exists in P1-B --
|
|
-- rows are seeded/edited directly (phpMyAdmin) until a management UI lands.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_thresholds (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
device_id VARCHAR(64) NOT NULL,
|
|
camera_id VARCHAR(64) NULL,
|
|
zone_id CHAR(32) NULL,
|
|
`key` VARCHAR(64) NOT NULL,
|
|
`value` VARCHAR(255) NOT NULL,
|
|
updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적
|
|
updated_at BIGINT UNSIGNED NULL,
|
|
PRIMARY KEY (id),
|
|
KEY ix_thresholds_device (device_id),
|
|
UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- nonces: replay-protection store for ingest/config/heartbeat HMAC requests.
|
|
-- `nonce` is the PRIMARY KEY (unique) so PdoNonceRepository::remember() is
|
|
-- safe against a duplicate-insert race: IngestController already calls
|
|
-- seen() before remember(), and a thrown duplicate-key error on a racing
|
|
-- remember() is an acceptable, expected outcome (closes the seen()/remember()
|
|
-- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard,
|
|
-- not the earlier seen() check).
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_nonces (
|
|
nonce VARCHAR(190) NOT NULL,
|
|
expires_at BIGINT UNSIGNED NOT NULL,
|
|
PRIMARY KEY (nonce),
|
|
KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM move_nonces WHERE expires_at < ?` cleanup job
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- rate_limits: per-key sliding-window counters for ingest rate limiting. The
|
|
-- (rate_key, window_start) primary key backs an atomic
|
|
-- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in
|
|
-- PdoRateLimiterRepository::hit() -- no read-modify-write race window.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_rate_limits (
|
|
rate_key VARCHAR(190) NOT NULL,
|
|
window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket
|
|
count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (rate_key, window_start),
|
|
-- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing
|
|
-- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path.
|
|
KEY ix_rate_limits_window (window_start)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- audit_log: append-only action log (acknowledge / zone save+delete / login /
|
|
-- threshold edit / suppression / handover).
|
|
--
|
|
-- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and
|
|
-- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or
|
|
-- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even
|
|
-- though shared hosting cannot grant append-only rights at the DB level. Verified by
|
|
-- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL
|
|
-- hashes and the verifier reports them as the pre-chain segment rather than as damage.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_audit_log (
|
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
actor VARCHAR(190) NOT NULL,
|
|
action VARCHAR(64) NOT NULL,
|
|
target VARCHAR(190) NOT NULL,
|
|
before_json JSON NOT NULL,
|
|
after_json JSON NOT NULL,
|
|
ts BIGINT UNSIGNED NOT NULL,
|
|
prev_hash CHAR(64) NULL,
|
|
entry_hash CHAR(64) NULL,
|
|
PRIMARY KEY (id),
|
|
KEY ix_audit_log_ts (ts),
|
|
KEY ix_audit_log_actor (actor),
|
|
KEY ix_audit_log_entry_hash (entry_hash)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- suppressions: planned-work alarm suppression (정비 모드 / 작업허가).
|
|
-- Detection is NOT paused -- events are still ingested and still appear in the console
|
|
-- and in reports. Only NOTIFICATION is withheld, and every suppression carries a
|
|
-- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry.
|
|
-- Rationale: the single largest source of false alarms is planned maintenance, and a
|
|
-- console that cries wolf during every shutdown is a console the site switches off.
|
|
-- ---------------------------------------------------------------------------
|
|
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;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- alert_channels / alert_log: how a detection reaches a human.
|
|
-- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network
|
|
-- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path
|
|
-- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house
|
|
-- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the
|
|
-- event still being unacknowledged, which is what turns "we detected it" into "someone
|
|
-- was made to look at it".
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_alert_channels (
|
|
channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
site VARCHAR(64) NULL,
|
|
kind VARCHAR(16) NOT NULL,
|
|
name VARCHAR(190) NOT NULL,
|
|
config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials.
|
|
min_severity VARCHAR(16) NOT NULL DEFAULT 'high',
|
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
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;
|
|
|
|
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,
|
|
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),
|
|
-- The dispatcher runs from cron and overlapping runs are expected; this unique key is
|
|
-- the only thing standing between a site and the same fall being phoned in five times.
|
|
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;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- shift_handovers: who took responsibility for what, and when.
|
|
-- The 24h brief tells an operator what happened; it does not record that anyone
|
|
-- accepted the backlog. Without that, unacknowledged events evaporate across a shift
|
|
-- change with nobody accountable for them.
|
|
-- ---------------------------------------------------------------------------
|
|
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;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it
|
|
-- stands for, the control currently in place, and the 5x5 rating. This is what lets the
|
|
-- system print "what are we controlling and how well", which is the artefact a safety
|
|
-- management system is actually audited on.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE move_risk_controls (
|
|
control_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
site VARCHAR(64) NULL,
|
|
event_type VARCHAR(32) NOT NULL,
|
|
hazard VARCHAR(300) NOT NULL,
|
|
control VARCHAR(500) NOT NULL,
|
|
severity_rating TINYINT UNSIGNED NOT NULL,
|
|
likelihood_rating TINYINT UNSIGNED NOT NULL,
|
|
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;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- device_metrics: heartbeat telemetry as a TIME SERIES.
|
|
-- move_devices holds only the latest value, so a node whose inference latency doubled
|
|
-- overnight looks identical to a healthy one. Appending each heartbeat is what makes
|
|
-- slow degradation visible before it becomes a monitoring outage.
|
|
-- ---------------------------------------------------------------------------
|
|
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;
|
|
|
|
-- ===========================================================================
|
|
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
|
|
-- do NOT copy the statements below by hand — they are SQL comments and will not
|
|
-- run. Apply the RUNNABLE migration instead:
|
|
--
|
|
-- cloud/migrations/2026-07-17-p1-9-p3-2b.sql
|
|
-- cloud/migrations/2026-07-20-move-table-prefix.sql (run this one LAST)
|
|
--
|
|
-- The second migration renames every table to the `move_` prefix used above and
|
|
-- by App\Repository\Pdo\* -- an existing DB must be renamed before deploy or all
|
|
-- queries fail with "table doesn't exist".
|
|
--
|
|
-- It adds devices.site / events.site / events.event_uuid, the retention and
|
|
-- rate-limit reap indexes, AND backfills events.site from move_devices.site (without
|
|
-- that backfill, historical events vanish for a scoped operator). Run it once in
|
|
-- phpMyAdmin BEFORE deploying code that reads these columns, then provision:
|
|
-- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1';
|
|
-- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a';
|
|
-- A user with site_scope = NULL is a global operator; '' is NOT global (matches
|
|
-- nothing — a blank scope fails closed).
|
|
-- ===========================================================================
|