refactor(db)!: DB 테이블 전체 leak_ 접두사 통일 + 구 마이그레이션 폐기

같은 cafe24 DB의 다른 시스템 테이블과 혼동/충돌을 막기 위해 5개 테이블을
leak_ 네임스페이스로 격리:
  sensor_log→leak_sensor_log, sensor_status→leak_sensor_status,
  sensor_metric→leak_sensor_metric, sms_log→leak_sms_log,
  ingest_nonce→leak_ingest_nonce (leak_incident/leak_photo는 이미 접두사).
컬럼명은 불변(sensor_id, is_leak 등) — 테이블명만 변경.

sql/migration_add_leak.sql, migration_drop_leak.sql 삭제: 리네임 후 구버전
무접두 테이블 대상이라 실행 즉시 에러. 신규 설치는 schema_sht30.sql 하나로 완결.

BREAKING CHANGE: 기존 무접두 테이블 배포와 호환 안 됨(신규 설치 전용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
This commit is contained in:
cw2007 2026-07-02 18:50:56 +09:00
parent c8b448c225
commit 9fef7031fc
13 changed files with 70 additions and 282 deletions

View file

@ -23,7 +23,7 @@ function check_offline_sensors(): int {
$db = get_db();
$stmt = $db->prepare("
SELECT * FROM sensor_status
SELECT * FROM leak_sensor_status
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
AND offline_alerted = 0
");
@ -36,7 +36,7 @@ function check_offline_sensors(): int {
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
$claim = $db->prepare("
UPDATE sensor_status SET offline_alerted = 1
UPDATE leak_sensor_status SET offline_alerted = 1
WHERE sensor_id = :sensor_id
AND offline_alerted = 0
AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
@ -66,7 +66,7 @@ function check_offline_sensors(): int {
if (!$sent && SMS_RETRY_OFFLINE) {
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
$db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
$db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
->execute([':sid' => $sensor['sensor_id']]);
error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
}

View file

@ -4,7 +4,7 @@
//
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구,
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력).
// 데이터 출처: leak_sms_log(message 태그 prefix 기준), leak_sensor_metric(온습도 측정 이력).
// =============================================================================
require_once __DIR__ . '/config.php';
@ -80,7 +80,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
$stmt = $db->prepare("
SELECT
" . sms_summary_select_sql() . "
FROM sms_log
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
@ -116,7 +116,7 @@ $stmt = $db->prepare("
AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity
FROM sensor_metric
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
@ -133,7 +133,7 @@ $summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
$stmt = $db->prepare("
SELECT sensor_id, sensor_name,
" . sms_summary_select_sql() . "
FROM sms_log
FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name
");
@ -154,7 +154,7 @@ $stmt = $db->prepare("
MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity,
MAX(measured_at) AS last_measured_at
FROM sensor_metric
FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id, sensor_name, device_location
");

View file

@ -48,7 +48,7 @@ function collect_ops_checks(): array {
$db = get_db();
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log'];
$required_tables = ['leak_sensor_log', 'leak_sensor_status', 'leak_sensor_metric', 'leak_sms_log'];
foreach ($required_tables as $table) {
$exists = ops_table_exists($db, $table);
$checks[] = ops_check(
@ -63,30 +63,30 @@ function collect_ops_checks(): array {
}
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
if (ops_table_exists($db, 'sensor_metric')) {
if (ops_table_exists($db, 'leak_sensor_metric')) {
$recent_metric_stmt = $db->query("
SELECT COUNT(*)
FROM sensor_metric
FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
");
$recent_metric = (int)$recent_metric_stmt->fetchColumn();
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신');
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'leak_sensor_metric 최근 수신');
}
if (ops_table_exists($db, 'sms_log')) {
if (ops_table_exists($db, 'leak_sms_log')) {
$recent_sms_fail_stmt = $db->query("
SELECT COUNT(*)
FROM sms_log
FROM leak_sms_log
WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
");
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수');
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'leak_sms_log 최근 실패 건수');
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
$sms_balance = latest_sms_balance($db);
if ($sms_balance !== null) {
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여');
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'leak_sms_log 최근 발송 잔여');
}
}
} catch (Throwable $e) {

View file

@ -46,9 +46,9 @@ $summary = [
'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS,
'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS,
'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS,
'sms_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
'leak_sms_log' => ['matched' => 0, 'rows_deleted' => 0],
'leak_sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
'leak_sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
'admin_audit' => ['kept_lines' => null, 'removed_lines' => null],
];
@ -62,16 +62,16 @@ try {
}
$smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS);
$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
$summary['leak_sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
$summary['leak_sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
$sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS);
$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
$summary['leak_sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
$summary['leak_sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
$metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS);
$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
$summary['leak_sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
$summary['leak_sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
$auditPath = admin_audit_log_path();
if (is_file($auditPath)) {
@ -113,9 +113,9 @@ if (is_file($auditPath)) {
admin_audit_log('retention_cleanup', 'system', true, [
'mode' => $summary['mode'],
'sms_matches' => $summary['sms_log']['matched'],
'sensor_matches' => $summary['sensor_log']['matched'],
'metric_matches' => $summary['sensor_metric']['matched'],
'sms_matches' => $summary['leak_sms_log']['matched'],
'sensor_matches' => $summary['leak_sensor_log']['matched'],
'metric_matches' => $summary['leak_sensor_metric']['matched'],
]);
header('Content-Type: application/json; charset=utf-8');

View file

@ -30,8 +30,8 @@ $control_rows = [
['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'],
['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'],
['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'],
['로그관리', '센서 측정 이력(sensor_metric)·SMS 발송 이력(sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, sensor_metric 이력'],
['로그관리', '센서 측정 이력(leak_sensor_metric)·SMS 발송 이력(leak_sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, leak_sensor_metric 이력'],
['백업', 'DB/설정 백업 목록과 복구 테스트 증적 관리', 'scripts/backup_evidence.php', 'backup-evidence 보고서'],
];

View file

@ -169,7 +169,7 @@ function send_sms_cafe24(string $receiver, string $message): array {
}
/**
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다.
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다.
*
* @param PDO $db
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
@ -191,7 +191,7 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
}
$stmt = $db->prepare("
INSERT INTO sms_log
INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at)
VALUES
@ -234,7 +234,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
}
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
$recent = $db->prepare("
SELECT sent_at FROM sms_log
SELECT sent_at FROM leak_sms_log
WHERE message LIKE :tag
ORDER BY sent_at DESC
LIMIT 1
@ -254,7 +254,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message);
$stmt = $db->prepare("
INSERT INTO sms_log
INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at)
VALUES
@ -329,7 +329,7 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool {
/**
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태
* breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
*/
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches = [];
@ -379,7 +379,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
/**
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준).
*/
function metric_was_alerting(PDO $db, int $sensor_id): bool {
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
@ -397,7 +397,7 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
$likes[] = "message LIKE {$key}";
$params[$key] = $tag . '%';
}
$sql = "SELECT message FROM sms_log
$sql = "SELECT message FROM leak_sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (" . implode(' OR ', $likes) . ")
@ -442,7 +442,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
@ -466,9 +466,9 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
}
try {
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
// ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ──────────────
$recent = $db->prepare("
SELECT sent_at FROM sms_log
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
@ -567,9 +567,9 @@ function send_leak_alert_sms(array $sensor_data): bool {
}
try {
// ── 쿨다운 (sms_log 메시지 prefix 기준) ──
// ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ──
$recent = $db->prepare("
SELECT sent_at FROM sms_log
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
@ -621,7 +621,7 @@ function send_leak_recovery_sms(array $sensor_data): bool {
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
$stmt = $db->prepare("
SELECT message FROM sms_log
SELECT message FROM leak_sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery)
@ -702,14 +702,14 @@ function send_device_online_sms(array $sensor_data): bool {
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
*/
function build_health_summary(PDO $db): string {
$online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn();
$total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn();
$online = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status WHERE offline_alerted = 0")->fetchColumn();
$total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM leak_sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
$alerts = (int)$db->query("
SELECT COUNT(*) FROM sms_log
SELECT COUNT(*) FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND status = 'success'
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
@ -735,7 +735,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
return;
}
$today = $db->prepare("
SELECT 1 FROM sms_log
SELECT 1 FROM leak_sms_log
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
LIMIT 1
");
@ -785,7 +785,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
*/
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
$stmt = $db->prepare("
SELECT sent_at FROM sms_log
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE :tag
ORDER BY sent_at DESC LIMIT 1
");
@ -828,7 +828,7 @@ function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
}
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
$sql = "SELECT message, sent_at FROM sms_log
$sql = "SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND status = 'success'
AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC LIMIT 1";
@ -848,7 +848,7 @@ function maybe_escalate_alerts(PDO $db): void {
return;
}
$alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION];
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll();
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) {
$sid = (int)$s['sensor_id'];
$row = latest_alert_state_row($db, $sid);
@ -897,12 +897,12 @@ function maybe_detect_stuck_sensors(PDO $db): void {
return;
}
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll();
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) {
$sid = (int)$s['sensor_id'];
$stmt = $db->prepare("
SELECT temperature_c, humidity_percent
FROM sensor_metric
FROM leak_sensor_metric
WHERE sensor_id = :sid
ORDER BY measured_at DESC
LIMIT {$n}

View file

@ -11,7 +11,7 @@
# ─────────────────────────────────────────────
# 센서 식별 (서버 대시보드 표시용; 비밀 아님)
# SENSOR_ID 는 서버 sensor_metric / sensor_status 의 키. 대시보드 기본 차트=2.
# SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2.
# ─────────────────────────────────────────────
SENSOR_ID = 2
SENSOR_NAME = "2번 센서 (SHT30)"

View file

@ -173,7 +173,7 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
# 측정 루프 (firmware sht30_measure_task 대응)
# ─────────────────────────────────────────────
def measure_loop(sensor: SHT30):
# 최초 성공 측정만 startup, 이후 periodic (서버 sensor_log 1회 startup 기록)
# 최초 성공 측정만 startup, 이후 periodic (서버 leak_sensor_log 1회 startup 기록)
first_success = True
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")

View file

@ -115,7 +115,7 @@ def save_data_flow():
("1. 온습도 측정", ["SHT30 측정", "온도/습도 5분 주기"]),
("2. 단말 보고", ["측정값 수집", "periodic/startup", "정상 보고"]),
("3. API 전송", ["HTTPS/TLS", "HMAC/서명 검증", "API 키 일치"]),
("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "sensor_metric", "sensor_log", "sms_log"]),
("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "leak_sensor_metric", "leak_sensor_log", "leak_sms_log"]),
("5. 알림/대응", ["임계 SMS 발송", "정상복귀 SMS", "대시보드 확인"]),
]
xs = [45, 275, 505, 735, 965]

View file

@ -1,105 +0,0 @@
-- =============================================================================
-- migration_add_leak.sql - SHT30 전용 서버에 누수(leak) 수신을 다시 추가(통합)
--
-- [목적] 1번 모듈(누수+카메라, sensor_id=1)과 2번 모듈(SHT30 온습도, sensor_id=2)이
-- "하나의 cafe24 서버 php/" 로 함께 보고하도록 통합한다. 이 마이그레이션은
-- sql/migration_drop_leak.sql 가 제거했던 누수 요소를 "가산(additive)·멱등"으로
-- 되돌린다(= migration_drop_leak.sql 의 역방향, 단 데이터 삭제 없이 가산만 수행).
--
-- [안전성] 운영 중인 SHT30 서버에서 그대로 실행해도 안전하다:
-- - sensor_metric(SHT30 측정) / sms_log / sensor_status / ingest_nonce 등 기존 테이블·
-- 데이터는 전혀 건드리지 않는다.
-- - 모든 변경은 "없을 때만 추가"(IF NOT EXISTS / information_schema 가드)이므로
-- 여러 번 재실행해도 오류가 나지 않는다.
--
-- [되돌리는 항목]
-- - sensor_log.event_type ENUM 에 leak_alert / leak_recovery 재추가
-- - sensor_log.is_leak 컬럼(없으면 추가)
-- - sensor_status.is_leak 컬럼(없으면 추가)
-- - leak_incident 테이블(없으면 생성) — 누수 사고(에피소드) 시작/복구 추적
-- - leak_photo 테이블(없으면 생성) — 누수 시 촬영 사진 메타데이터
--
-- 신규(통합) 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하면 된다
-- (schema_sht30.sql 이 이미 누수 요소를 포함하도록 통합되어 있다).
-- =============================================================================
SET NAMES utf8mb4;
-- 1) sensor_log.event_type: 누수 enum 값을 다시 허용(멱등 — 동일 enum 재적용은 안전).
ALTER TABLE `sensor_log`
MODIFY `event_type` ENUM('startup','periodic','leak_alert','leak_recovery')
NOT NULL DEFAULT 'periodic';
-- 2~3) 컬럼/인덱스 추가는 "없을 때만" 수행해 재실행해도 오류가 없게 한다.
-- (MySQL/MariaDB 는 ADD COLUMN IF NOT EXISTS 호환이 일정치 않으므로
-- information_schema 로 가드하는 멱등 프로시저를 사용한다.)
DROP PROCEDURE IF EXISTS _mig_add_leak;
DELIMITER //
CREATE PROCEDURE _mig_add_leak()
BEGIN
DECLARE db VARCHAR(64);
SELECT DATABASE() INTO db;
-- sensor_log.is_leak: 누수 여부(0/1) — 없으면 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_log`
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '누수 여부 (1=누수, 0=정상)' AFTER `event_type`;
END IF;
-- sensor_status.is_leak: 최신 누수 상태(0/1) — 없으면 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_status`
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '최신 누수 상태 (1=누수, 0=정상)' AFTER `last_event_type`;
END IF;
END //
DELIMITER ;
CALL _mig_add_leak();
DROP PROCEDURE IF EXISTS _mig_add_leak;
-- 4) leak_incident: 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은
-- 센서의 leak_recovery 시 가장 최근 미복구 행을 닫는다(recovered_at 채움).
CREATE TABLE IF NOT EXISTS `leak_incident` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)',
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`),
INDEX `idx_started_at` (`started_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사고(에피소드) 추적 (시작/복구)';
-- 5) leak_photo: 누수 시 촬영한 사진 메타데이터. 실제 파일은 php/var/photos/ 에 저장되고
-- filename 은 서버가 생성한 무작위 파일명(클라이언트 경로 미사용 — 경로주입 방지).
CREATE TABLE IF NOT EXISTS `leak_photo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',
`mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg',
`bytes` INT UNSIGNED NOT NULL DEFAULT 0,
`captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_log_id` (`log_id`),
INDEX `idx_captured_at` (`captured_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사진 메타데이터 (파일은 var/photos/)';

View file

@ -1,106 +0,0 @@
-- =============================================================================
-- migration_drop_leak.sql - 기존 누수(v2605) 설치 → SHT30 온습도 전용 전환
--
-- ⚠️ 비가역(DROP COLUMN/TABLE) 변경입니다. 반드시 DB 전체 백업 후 실행하세요.
-- 운영 데이터(과거 누수 이벤트/사진/사고 이력)가 삭제됩니다.
--
-- 이미 v2605 스키마(sensor_log/sensor_status/sensor_metric/sms_log + leak_*)가
-- 적용된 운영 DB를 SHT30 전용으로 정리합니다. 측정 이력(sensor_metric)과 SMS
-- 이력(sms_log)은 보존합니다.
--
-- 신규 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하세요.
-- =============================================================================
SET NAMES utf8mb4;
-- 0) sensor_metric 이 없던 구버전 대비: 없으면 생성(있으면 무시).
CREATE TABLE IF NOT EXISTS `sensor_metric` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`metric_type` VARCHAR(30) NOT NULL DEFAULT 'sht30',
`temperature_c` DECIMAL(6,2) NULL,
`humidity_percent` DECIMAL(5,2) NULL,
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal',
`measured_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_sensor_measured` (`sensor_id`, `measured_at`),
INDEX `idx_device_sensor` (`device_id`, `sensor_id`),
INDEX `idx_metric_type` (`metric_type`, `created_at`),
INDEX `idx_status_measured` (`metric_status`, `measured_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1) 누수 전용 테이블 제거
DROP TABLE IF EXISTS `leak_photo`;
DROP TABLE IF EXISTS `leak_incident`;
-- 2) sensor_log: 누수 enum 값 정리 → event_type 재정의 (둘 다 멱등)
-- (leak_alert/leak_recovery 행이 있으면 periodic 으로 강등 후 enum 축소.
-- 이미 정리된 DB면 매칭 행이 없어 no-op, MODIFY 도 동일 enum 재적용이라 안전.)
UPDATE `sensor_log` SET `event_type` = 'periodic'
WHERE `event_type` IN ('leak_alert', 'leak_recovery');
ALTER TABLE `sensor_log`
MODIFY `event_type` ENUM('startup','periodic') NOT NULL DEFAULT 'periodic';
-- 3~4) 인덱스/컬럼 제거·추가는 "존재할 때만" 수행하여 재실행해도 오류가 없게 한다.
-- (MySQL/MariaDB 는 DROP COLUMN/INDEX 에 IF EXISTS 호환이 일정치 않으므로
-- information_schema 로 가드하는 멱등 프로시저를 사용한다.)
DROP PROCEDURE IF EXISTS _mig_drop_leak;
DELIMITER //
CREATE PROCEDURE _mig_drop_leak()
BEGIN
DECLARE db VARCHAR(64);
SELECT DATABASE() INTO db;
-- sensor_log: is_leak 인덱스/컬럼 제거, event_type 인덱스 추가
IF EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_is_leak') THEN
ALTER TABLE `sensor_log` DROP INDEX `idx_is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_log` DROP COLUMN `is_leak`;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_event_type') THEN
ALTER TABLE `sensor_log` ADD INDEX `idx_event_type` (`event_type`, `created_at`);
END IF;
-- sensor_status: 누수 컬럼/인덱스 제거 (offline_alerted 는 오프라인 SMS에 필요 → 유지)
IF EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND INDEX_NAME = 'idx_is_leak') THEN
ALTER TABLE `sensor_status` DROP INDEX `idx_is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_status` DROP COLUMN `is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'leak_periodic_count') THEN
ALTER TABLE `sensor_status` DROP COLUMN `leak_periodic_count`;
END IF;
-- sensor_metric: 임계 경보 조회용 인덱스 추가(없을 때만)
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_metric' AND INDEX_NAME = 'idx_status_measured') THEN
ALTER TABLE `sensor_metric` ADD INDEX `idx_status_measured` (`metric_status`, `measured_at`);
END IF;
END //
DELIMITER ;
CALL _mig_drop_leak();
DROP PROCEDURE IF EXISTS _mig_drop_leak;
-- 5) 수집 재전송(replay) 멱등 nonce 테이블 추가(없을 때만 — 가산/멱등).
-- api/sensor_data.php 가 서명을 nonce 로 기록해 시계오차 창 내 중복 수신을 멱등
-- 처리한다(중복 저장·중복 SMS 방지). 이 테이블이 없어도 수집은 동작한다.
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`signature`),
INDEX `idx_seen_at` (`seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';

View file

@ -5,24 +5,23 @@
-- 신규 설치: 이 파일 하나만 실행합니다.
-- SOURCE /path/to/sql/schema_sht30.sql;
--
-- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을
-- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시
-- 더하려면 sql/migration_add_leak.sql(가산·멱등).
-- v2607: 모든 테이블에 leak_ 접두사 적용 — 같은 DB 의 다른(비관련) 테이블과 격리.
-- (구버전 무접두 테이블 대상 마이그레이션 파일들은 리네임과 함께 폐기 — git 이력 참고.)
--
-- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소:
-- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
-- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼,
-- - 온습도(SHT30): leak_sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
-- - 누수(leak): leak_sensor_log.is_leak / leak_sensor_status.is_leak 컬럼,
-- event_type 의 leak_alert / leak_recovery,
-- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블.
-- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며,
-- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
-- 발송 이력은 leak_sms_log 에 남깁니다(leak_sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
-- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다.
-- =============================================================================
SET NAMES utf8mb4;
SET time_zone = '+09:00';
CREATE TABLE IF NOT EXISTS `sensor_log` (
CREATE TABLE IF NOT EXISTS `leak_sensor_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL COMMENT 'STM32 기기 ID',
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
@ -43,7 +42,7 @@ CREATE TABLE IF NOT EXISTS `sensor_log` (
COLLATE=utf8mb4_unicode_ci
COMMENT='센서 이벤트 전체 로그';
CREATE TABLE IF NOT EXISTS `sensor_status` (
CREATE TABLE IF NOT EXISTS `leak_sensor_status` (
`sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
@ -61,7 +60,7 @@ CREATE TABLE IF NOT EXISTS `sensor_status` (
COLLATE=utf8mb4_unicode_ci
COMMENT='센서별 최신 상태 (업서트)';
CREATE TABLE IF NOT EXISTS `sensor_metric` (
CREATE TABLE IF NOT EXISTS `leak_sensor_metric` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
@ -85,7 +84,7 @@ CREATE TABLE IF NOT EXISTS `sensor_metric` (
COLLATE=utf8mb4_unicode_ci
COMMENT='SHT30 온습도 측정 이력 (온도/습도/임계 상태)';
CREATE TABLE IF NOT EXISTS `sms_log` (
CREATE TABLE IF NOT EXISTS `leak_sms_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sensor_id` TINYINT UNSIGNED NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
@ -110,7 +109,7 @@ CREATE TABLE IF NOT EXISTS `sms_log` (
-- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이
-- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은
-- 동작하며 멱등 방어만 비활성화된다 — graceful degrade).
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
CREATE TABLE IF NOT EXISTS `leak_ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
COMMENT '요청 서명(X-Signature) — 멱등 키',
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@ -133,8 +132,8 @@ CREATE TABLE IF NOT EXISTS `leak_incident` (
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert leak_sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery leak_sensor_log.id',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@ -149,7 +148,7 @@ CREATE TABLE IF NOT EXISTS `leak_incident` (
-- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다.
CREATE TABLE IF NOT EXISTS `leak_photo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert leak_sensor_log.id',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',

View file

@ -156,7 +156,7 @@ $opsChecks = file_text($root . '/php/ops_checks.php');
check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.');
check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.');
check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.');
check(str_contains($schemaSql, 'ingest_nonce'), 'schema_sht30.sql should create the ingest_nonce table.');
check(str_contains($schemaSql, 'leak_ingest_nonce'), 'schema_sht30.sql should create the leak_ingest_nonce table.');
// sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400)
check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');