diff --git a/php/sms_send.php b/php/sms_send.php index 6ba5d64..1890620 100644 --- a/php/sms_send.php +++ b/php/sms_send.php @@ -12,6 +12,10 @@ // ============================================================================= require_once __DIR__ . '/config.php'; +// dew_point_c() 는 forecast.php 의 정규(nullable-safe) 정의를 단일 출처로 사용한다. +// (과거 이 파일에도 중복 정의가 있어 forecast.php 와 함께 로드되는 dashboard 에서 +// Cannot redeclare 치명오류가 났다 — 단일 정의로 통합.) +require_once __DIR__ . '/forecast.php'; /** * 발신번호를 3분할 (sphone1, sphone2, sphone3) @@ -283,19 +287,7 @@ function metric_value_str(float $value): string { return $s; } -/** - * 이슬점(℃) — Magnus 근사(물, 약 -45~60℃, 계수 a=17.62 b=243.12). - * 상대습도 0% 이하면 이슬점 정의 불가로 null(결로 위험 없음으로 취급). - */ -function dew_point_c(float $temp_c, float $rh): ?float { - if ($rh <= 0.0) { - return null; - } - $a = 17.62; - $b = 243.12; - $gamma = ($a * $temp_c) / ($b + $temp_c) + log($rh / 100.0); - return ($b * $gamma) / ($a - $gamma); -} +// dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용. /** * 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null. @@ -412,6 +404,38 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool { return !str_starts_with($msg, SMS_TAG_RECOVERY); } +/** + * 에피소드 인지 쿨다운 판정(순수 함수 — DB/네트워크 없음, 테스트 용이). + * + * $latestMessage/$latestSentAt 은 "이 경보 태그" 또는 "복구 태그" 로 시작하는 SMS 중 + * 가장 최근 1건(없으면 둘 다 null). 최신이 경보 태그로 시작하고(=아직 미복구) 발송 + * 시각이 쿨다운 이내이면 true(생략). 최신이 복구면 에피소드 종료 → false(발송). + * + * 이렇게 하면 "같은 경보 지속 중"의 중복 폭주는 계속 억제하되, 복구 후 재발한 경보는 + * 별개 에피소드로 보아 즉시 발송한다(쿨다운이 복구 직후의 새 사고를 삼키던 버그 수정). + * + * @param ?string $latestMessage 최신 경보/복구 SMS 본문(없으면 null) + * @param ?string $latestSentAt 그 sent_at(파싱 가능한 일시 문자열, 없으면 null) + * @param string $alertTag 이 경보의 태그(prefix). 최신이 이걸로 시작해야 억제 후보. + * @param int $cooldownSec 쿨다운(초) + * @param int $now 현재 Unix 시각(테스트 주입용) + */ +function alert_cooldown_suppresses( + ?string $latestMessage, + ?string $latestSentAt, + string $alertTag, + int $cooldownSec, + int $now +): bool { + if ($latestMessage === null || $latestSentAt === null) { + return false; // 이력 없음 → 발송 + } + if (!str_starts_with($latestMessage, $alertTag)) { + return false; // 최신이 복구(또는 다른 상태) → 새 에피소드 → 발송 + } + return ($now - strtotime($latestSentAt)) < $cooldownSec; +} + /** * 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함). * @@ -466,20 +490,31 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value): } try { - // ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ────────────── + // ── 종류별 쿨다운 (에피소드 인지) ──────────────────────────────── + // 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이 + // 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면 + // 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다. + // (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에 + // 걸려 통째로 누락됐다.) $recent = $db->prepare(" - SELECT sent_at FROM leak_sms_log + SELECT message, sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' - AND message LIKE :tag - ORDER BY sent_at DESC + AND (message LIKE :alert OR message LIKE :recovery) + ORDER BY sent_at DESC, id DESC LIMIT 1 "); - $recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']); - $last_at = $recent->fetchColumn(); - if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) { - $elapsed = time() - strtotime($last_at); - error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); + $recent->execute([ + ':sensor_id' => $sensor_id, + ':alert' => $tag . '%', + ':recovery' => SMS_TAG_RECOVERY . '%', + ]); + $row = $recent->fetch(); + $lastMsg = ($row === false) ? null : (string)$row['message']; + $lastAt = ($row === false) ? null : (string)$row['sent_at']; + if (alert_cooldown_suppresses($lastMsg, $lastAt, $tag, METRIC_ALERT_COOLDOWN_SEC, time())) { + $elapsed = time() - strtotime((string)$lastAt); + error_log("[SMS] {$tag} 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); return false; } @@ -567,20 +602,31 @@ function send_leak_alert_sms(array $sensor_data): bool { } try { - // ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ── + // ── 쿨다운 (에피소드 인지) ── + // [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고 + // 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면 + // 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다. + // (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에 + // 걸려 통째로 누락됐다.) $recent = $db->prepare(" - SELECT sent_at FROM leak_sms_log + SELECT message, sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' - AND message LIKE :tag - ORDER BY sent_at DESC + AND (message LIKE :alert OR message LIKE :recovery) + ORDER BY sent_at DESC, id DESC LIMIT 1 "); - $recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']); - $last_at = $recent->fetchColumn(); - if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) { - $elapsed = time() - strtotime($last_at); - error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)"); + $recent->execute([ + ':sensor_id' => $sensor_id, + ':alert' => SMS_TAG_LEAK_ALERT . '%', + ':recovery' => SMS_TAG_LEAK_RECOVERY . '%', + ]); + $row = $recent->fetch(); + $lastMsg = ($row === false) ? null : (string)$row['message']; + $lastAt = ($row === false) ? null : (string)$row['sent_at']; + if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_LEAK_ALERT, LEAK_ALERT_COOLDOWN_SEC, time())) { + $elapsed = time() - strtotime((string)$lastAt); + error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)"); return false; } diff --git a/run_tests.sh b/run_tests.sh index 2da75f4..13ff142 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -27,6 +27,7 @@ echo "=== 1) PHP 회귀 테스트 ===" run_php tests/raw_body_signature_test.php run_php tests/security_hardening_test.php run_php tests/threshold_sms_test.php +run_php tests/cooldown_episode_test.php # 에피소드 인지 쿨다운(복구 후 재발 누락 회귀) run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴 run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수 diff --git a/tests/cooldown_episode_test.php b/tests/cooldown_episode_test.php new file mode 100644 index 0000000..19f9d0b --- /dev/null +++ b/tests/cooldown_episode_test.php @@ -0,0 +1,76 @@ + date('Y-m-d H:i:s', $now - $secAgo); + +$LEAK = SMS_TAG_LEAK_ALERT; +$LEAK_R = SMS_TAG_LEAK_RECOVERY; +$HITEMP = SMS_TAG_HIGH_TEMP; +$METRIC_R = SMS_TAG_RECOVERY; +$body = fn(string $tag): string => $tag . "\n위치: 시험\n센서: t"; + +// ── 1) 이력 없음 → 발송(생략 아님) ────────────────────────── +check(alert_cooldown_suppresses(null, null, $LEAK, $COOL, $now) === false, + 'no history -> send (not suppressed)'); + +// ── 2) 지속 누수: 최신이 [누수감지] & 쿨다운 이내 → 생략 ───── +check(alert_cooldown_suppresses($body($LEAK), $at(60), $LEAK, $COOL, $now) === true, + 'ongoing leak within cooldown -> suppress (flood guard kept)'); + +// ── 3) 오래된 누수: 최신이 [누수감지] & 쿨다운 경과 → 발송 ─── +check(alert_cooldown_suppresses($body($LEAK), $at(3600), $LEAK, $COOL, $now) === false, + 'stale leak beyond cooldown -> send (periodic re-alert)'); + +// ── 4) [핵심 버그] 복구 직후 재발: 최신이 [누수복구] → 발송 ── +// 복구가 방금(1초 전) 있었어도, 새 누수는 별개 에피소드이므로 쿨다운 무시하고 발송해야 한다. +check(alert_cooldown_suppresses($body($LEAK_R), $at(1), $LEAK, $COOL, $now) === false, + 'BUG REGRESSION: leak right after [누수복구] -> send (must NOT be suppressed)'); + +// ── 5) 경계값: elapsed == cooldown 은 생략 아님(< 비교) ────── +check(alert_cooldown_suppresses($body($LEAK), $at($COOL), $LEAK, $COOL, $now) === false, + 'elapsed exactly == cooldown -> send (strict <)'); +check(alert_cooldown_suppresses($body($LEAK), $at($COOL - 1), $LEAK, $COOL, $now) === true, + 'elapsed cooldown-1 -> suppress'); + +// ── 6) 온습도 경보도 동일: 최신이 [정상복귀] → 발송 ───────── +check(alert_cooldown_suppresses($body($METRIC_R), $at(1), $HITEMP, $COOL, $now) === false, + 'metric: high_temp right after [정상복귀] -> send (episode reset)'); +// 지속 고온: 최신이 [고온경보] & 이내 → 생략 +check(alert_cooldown_suppresses($body($HITEMP), $at(60), $HITEMP, $COOL, $now) === true, + 'metric: ongoing high_temp within cooldown -> suppress'); + +// ── 7) 다른 종류 경보가 최신이면 이 종류 쿨다운에 안 걸림 ──── +// (쿼리도 이 종류+복구만 뽑지만, 순수함수는 prefix 로 방어) +check(alert_cooldown_suppresses($body(SMS_TAG_HIGH_HUMIDITY), $at(60), $HITEMP, $COOL, $now) === false, + 'metric: latest is different-kind alert -> this kind not suppressed'); + +if ($failures) { + fwrite(STDERR, "Cooldown episode test failures:\n"); + foreach ($failures as $f) { + fwrite(STDERR, "- {$f}\n"); + } + exit(1); +} + +echo "Cooldown episode-aware checks passed.\n"; diff --git a/tests/security_hardening_test.php b/tests/security_hardening_test.php index 145f575..1daddcc 100644 --- a/tests/security_hardening_test.php +++ b/tests/security_hardening_test.php @@ -206,7 +206,10 @@ check(str_contains($cron, 'maybe_send_daily_health_sms'), 'cron_heartbeat.php sh // ②c 이슬점/결로 경보 check(str_contains($config, 'METRIC_DEWPOINT_MARGIN_C'), 'config.php should define METRIC_DEWPOINT_MARGIN_C.'); check(str_contains($config, 'SMS_TAG_CONDENSATION'), 'config.php should define the condensation alert tag.'); -check(str_contains($smsSend, 'function dew_point_c'), 'sms_send.php should compute dew point.'); +// dew_point_c() 는 forecast.php 로 이동(단일 출처). sms_send.php 는 require 후 사용한다. +$forecast = file_text($root . '/php/forecast.php'); +check(str_contains($forecast, 'function dew_point_c'), 'forecast.php should define dew_point_c (single source).'); +check(str_contains($smsSend, 'dew_point_c('), 'sms_send.php should use dew_point_c() for condensation.'); check(str_contains($smsSend, 'condensation_risk'), 'sms_send.php should classify condensation_risk.'); // ②d 수신번호 마스킹