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:
parent
baf5b68fb8
commit
0728e45894
4 changed files with 158 additions and 32 deletions
108
php/sms_send.php
108
php/sms_send.php
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/여유/신선도/위험) 순수함수
|
||||||
|
|
||||||
|
|
|
||||||
76
tests/cooldown_episode_test.php
Normal file
76
tests/cooldown_episode_test.php
Normal 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";
|
||||||
|
|
@ -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 수신번호 마스킹
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue