fix(sms): 경보 쿨다운을 에피소드 인지로 전환 — 복구 직후 재발 누락 수정

누수/온습도 경보 쿨다운이 경보 태그만 조회해, 직전에 [누수복구]/[정상복귀]가
있었어도 이전 경보 발송 시각만 보고 억제했다. 그 결과 복구 직후 재발한 새 사고
(별개 에피소드)가 쿨다운 창(기본 30분) 동안 통째로 누락됐다.

- alert_cooldown_suppresses(): 경보/복구 중 "최신"이 경보이고(=미복구) 쿨다운
  이내일 때만 억제하는 순수 헬퍼 추가. 최신이 복구면 새 에피소드로 즉시 발송.
- send_leak_alert_sms / send_metric_alert_sms: 쿨다운 조회를 (경보|복구) 최신
  1건 기준으로 교체하고 위 헬퍼로 판정. 지속 경보 폭주 방지는 그대로 유지.
- tests/cooldown_episode_test.php: 복구 직후 재발 발송 등 9종 회귀 추가,
  run_tests.sh 에 등록.
- tests/security_hardening_test.php: dew_point_c 의 forecast.php 단일출처 이동에
  맞춰 낡은 단언 갱신(정의는 forecast.php, sms_send.php 는 사용 확인).

작업 트리에 있던 dew_point_c 중복정의 제거(forecast.php 단일출처화)도 sms_send.php
변경에 함께 포함된다.
This commit is contained in:
cw2007 2026-07-09 12:41:05 +09:00
parent baf5b68fb8
commit 0728e45894
4 changed files with 158 additions and 32 deletions

View file

@ -12,6 +12,10 @@
// ============================================================================= // =============================================================================
require_once __DIR__ . '/config.php'; 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) * 발신번호를 3분할 (sphone1, sphone2, sphone3)
@ -283,19 +287,7 @@ function metric_value_str(float $value): string {
return $s; return $s;
} }
/** // dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용.
* 이슬점(℃) — 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);
}
/** /**
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null. * 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
@ -412,6 +404,38 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
return !str_starts_with($msg, SMS_TAG_RECOVERY); 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 발송 (종류별 쿨다운 포함). * 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함).
* *
@ -466,20 +490,31 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
} }
try { try {
// ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ────────────── // ── 종류별 쿨다운 (에피소드 인지) ────────────────────────────────
// 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이
// 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면
// 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM leak_sms_log SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC ORDER BY sent_at DESC, id DESC
LIMIT 1 LIMIT 1
"); ");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']); $recent->execute([
$last_at = $recent->fetchColumn(); ':sensor_id' => $sensor_id,
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) { ':alert' => $tag . '%',
$elapsed = time() - strtotime($last_at); ':recovery' => SMS_TAG_RECOVERY . '%',
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); ]);
$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; return false;
} }
@ -567,20 +602,31 @@ function send_leak_alert_sms(array $sensor_data): bool {
} }
try { try {
// ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ── // ── 쿨다운 (에피소드 인지) ──
// [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고
// 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면
// 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다.
// (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에
// 걸려 통째로 누락됐다.)
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM leak_sms_log SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC ORDER BY sent_at DESC, id DESC
LIMIT 1 LIMIT 1
"); ");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']); $recent->execute([
$last_at = $recent->fetchColumn(); ':sensor_id' => $sensor_id,
if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) { ':alert' => SMS_TAG_LEAK_ALERT . '%',
$elapsed = time() - strtotime($last_at); ':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)"); ]);
$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; return false;
} }

View file

@ -27,6 +27,7 @@ echo "=== 1) PHP 회귀 테스트 ==="
run_php tests/raw_body_signature_test.php run_php tests/raw_body_signature_test.php
run_php tests/security_hardening_test.php run_php tests/security_hardening_test.php
run_php tests/threshold_sms_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/chaos_drill.php # SMS 실패 주입 경로 드릴
run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수 run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
// 에피소드 인지 쿨다운 회귀 테스트 (DB/네트워크 없이 순수 로직만).
// alert_cooldown_suppresses(): 복구 직후 재발한 경보를 쿨다운이 삼키던 버그 회귀 방지.
//
// 배경(버그): send_leak_alert_sms / send_metric_alert_sms 의 쿨다운이 경보 태그만 보고
// "최근 N초 내 경보 있으면 생략" 했다. 그 사이 [누수복구]/[정상복귀] 가 있었어도 무시 →
// 복구 직후 재발한 새 사고(별개 에피소드)가 통째로 누락됐다.
// 수정: 경보/복구 중 "최신"이 경보이고(=미복구) 쿨다운 이내일 때만 생략. 최신이 복구면 발송.
$root = dirname(__DIR__);
require_once $root . '/php/sms_send.php'; // config.php(태그 상수) + alert_cooldown_suppresses() 포함
$failures = [];
function check(bool $cond, string $msg): void {
global $failures;
if (!$cond) { $failures[] = $msg; }
}
check(function_exists('alert_cooldown_suppresses'), 'alert_cooldown_suppresses() should exist.');
$COOL = 1800; // 30분 쿨다운(테스트 고정값 — 상수와 무관하게 로직만 검증)
$now = 1_700_000_000; // 고정 기준시각(결정적). sent_at 은 date() 로 생성.
$at = fn(int $secAgo): string => 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";

View file

@ -206,7 +206,10 @@ check(str_contains($cron, 'maybe_send_daily_health_sms'), 'cron_heartbeat.php sh
// ②c 이슬점/결로 경보 // ②c 이슬점/결로 경보
check(str_contains($config, 'METRIC_DEWPOINT_MARGIN_C'), 'config.php should define METRIC_DEWPOINT_MARGIN_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($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.'); check(str_contains($smsSend, 'condensation_risk'), 'sms_send.php should classify condensation_risk.');
// ②d 수신번호 마스킹 // ②d 수신번호 마스킹