POSA_LEAKSMS/php/sms_send.php
cw2007 9fef7031fc 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
2026-07-02 18:50:56 +09:00

937 lines
39 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// =============================================================================
// sms_send.php - SMS 발송 모듈 (Cafe24 SMS API 사용)
// Cafe24: https://sslsms.cafe24.com/sms_sender.php
//
// 담당 알림:
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
// - 온습도 정상 복귀 : send_metric_recovery_sms()
// - 누수 감지 / 복구 (1번 모듈) : send_leak_alert_sms() / send_leak_recovery_sms()
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
// =============================================================================
require_once __DIR__ . '/config.php';
/**
* 발신번호를 3분할 (sphone1, sphone2, sphone3)
*
* @return array{0:string, 1:string, 2:string}
*/
function split_sender_number(string $phone): array {
$digits = preg_replace('/\D+/', '', $phone);
$len = strlen($digits);
if ($len < 9) {
throw new RuntimeException('발신번호 형식이 올바르지 않습니다: ' . $phone);
}
if ($len === 10) {
return [substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4)];
}
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
}
/**
* EUC-KR 로 인코딩된 문자열을 최대 바이트 수로 자른다(멀티바이트 경계 보존).
* Cafe24 단문(SMS)의 EUC-KR 길이 상한을 넘지 않도록 발송 직전에 사용한다.
*
* @param string $euckr EUC-KR 로 이미 인코딩된 바이트열
* @param int $maxBytes 허용 최대 바이트
*/
function sms_truncate_euckr(string $euckr, int $maxBytes): string {
if ($maxBytes <= 0 || strlen($euckr) <= $maxBytes) {
return $euckr;
}
if (function_exists('mb_strcut')) {
// mb_strcut 은 지정 바이트 안에서 멀티바이트 글자를 쪼개지 않는다.
$cut = mb_strcut($euckr, 0, $maxBytes, 'EUC-KR');
if ($cut !== '') {
return $cut;
}
}
// mbstring 미가용 폴백: EUC-KR 2바이트 글자 경계를 직접 맞춘다(0x00-0x7F 는 1바이트,
// 0xA1-0xFE 선행 바이트는 2바이트). maxBytes 위치가 2바이트 글자 중간이면 한 글자 뺀다.
$cut = substr($euckr, 0, $maxBytes);
$highBytes = 0;
for ($i = strlen($cut) - 1; $i >= 0 && ord($cut[$i]) >= 0x80; $i--) {
$highBytes++;
}
if ($highBytes % 2 === 1) {
$cut = substr($cut, 0, -1);
}
return $cut;
}
/**
* Cafe24 SMS API로 문자 발송
*
* @param string $receiver 수신자 번호 (숫자만)
* @param string $message 메시지 내용
* @return array ['success'=>bool, 'result_code'=>string, 'message'=>string]
*/
function send_sms_cafe24(string $receiver, string $message): array {
$receiver = preg_replace('/\D+/', '', $receiver);
if ($receiver === '' || trim($message) === '') {
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.'];
}
// 카오스 드릴: 실제 전송 없이 실패를 반환한다(실패 경로 시험). 운영에선 false.
if (SMS_FORCE_FAIL) {
return ['success' => false, 'result_code' => 'FORCED_FAIL', 'message' => '드릴: 강제 실패', 'remaining' => null];
}
[$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message));
if ($encodedMessage === false || $encodedMessage === '') {
return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패'];
}
// 단문(SMS, 'S') 길이 상한(EUC-KR 바이트). 초과분은 게이트웨이가 잘라/거부하므로
// 멀티바이트 경계를 보존하며 발송 전에 안전하게 자른다(무성 절단 방지).
$origBytes = strlen($encodedMessage);
$encodedMessage = sms_truncate_euckr($encodedMessage, SMS_MAX_EUCKR_BYTES);
if (strlen($encodedMessage) < $origBytes) {
error_log(sprintf('[Cafe24 SMS] 메시지 길이 초과 절단: %dB → %dB (상한 %dB)',
$origBytes, strlen($encodedMessage), SMS_MAX_EUCKR_BYTES));
}
$postData = [
'action' => 'go',
'user_id' => base64_encode(SMS_USER_ID),
'secure' => base64_encode(SMS_SECURE),
'msg' => base64_encode($encodedMessage),
'rphone' => base64_encode($receiver),
'sphone1' => base64_encode($sphone1),
'sphone2' => base64_encode($sphone2),
'sphone3' => base64_encode($sphone3),
'rdate' => base64_encode(''),
'rtime' => base64_encode(''),
'mode' => base64_encode('1'),
'returnurl' => base64_encode(''),
'testflag' => base64_encode(''),
'destination'=> strtr(base64_encode(''), '+/=', '-,'),
'repeatFlag' => base64_encode(''),
'repeatNum' => base64_encode(''),
'repeatTime' => base64_encode(''),
'smsType' => base64_encode('S'),
];
$ch = curl_init();
if ($ch === false) {
return ['success' => false, 'result_code' => 'CURL_INIT_ERR', 'message' => 'cURL 초기화 실패'];
}
curl_setopt_array($ch, [
CURLOPT_URL => SMS_ENDPOINT,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($ch);
$errorNo = curl_errno($ch);
$errorMsg = curl_error($ch);
curl_close($ch);
if ($response === false || $errorNo !== 0) {
$errText = $errorMsg !== '' ? $errorMsg : '알 수 없는 오류';
error_log(sprintf('[Cafe24 SMS] cURL 오류(code=%d): %s', $errorNo, $errText));
return ['success' => false, 'result_code' => 'CURL_ERR', 'message' => $errText];
}
$responseBody = trim($response);
if ($responseBody === '') {
return ['success' => false, 'result_code' => 'EMPTY_RESP', 'message' => '빈 응답'];
}
$parts = explode(',', $responseBody);
$resultCode = $parts[0] ?? '';
$remaining = $parts[1] ?? '';
error_log(sprintf('[Cafe24 SMS] 응답: result=%s, remaining=%s', $resultCode, $remaining));
$success = in_array(strtolower($resultCode), ['success', 'reserved'], true);
return [
'success' => $success,
'result_code' => $resultCode,
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}",
// 게이트웨이가 돌려준 잔여 건수(숫자면 int, 아니면 null). 잔액 사전경보에 사용.
'remaining' => is_numeric($remaining) ? (int)$remaining : null,
];
}
/**
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다.
*
* @param PDO $db
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
* @param string $message 발송할 메시지 본문
* @param string $logLabel error_log 식별 라벨
* @return bool 전원 발송 성공 여부
*/
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): bool {
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
foreach ($targets as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message);
if (($result['remaining'] ?? null) !== null) {
$lastRemaining = (int)$result['remaining'];
}
$stmt = $db->prepare("
INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at)
VALUES
(:sensor_id, :sensor_name, :device_id, :message, :receivers,
:status, :result_code, :result_message, NOW())
");
$stmt->execute([
':sensor_id' => $sensor_data['sensor_id'],
':sensor_name' => $name,
':device_id' => $sensor_data['device_id'] ?? '',
':message' => $message,
':receivers' => $receiverDigits,
':status' => $result['success'] ? 'success' : 'fail',
':result_code' => $result['result_code'],
':result_message' => $result['message'],
]);
if ($result['success']) {
error_log("[SMS] {$logLabel} 발송 성공 - 센서{$sensor_data['sensor_id']} → {$receiverDigits}");
} else {
error_log("[SMS] {$logLabel} 발송 실패 - {$receiverDigits}: {$result['result_code']} {$result['message']}");
$allSuccess = false;
}
}
// 발송 직후 잔여 건수가 임계 이하이면(쿨다운 1회) 사전경보. 직접 발송이라 재귀 없음.
maybe_warn_sms_balance($db, $lastRemaining);
return $allSuccess;
}
/**
* SMS 잔여 건수가 임계 이하이면 '[SMS 잔액부족]' 경고를 1회 발송한다(쿨다운 적용).
* 게이트웨이 응답에서 얻은 잔여 건수를 그대로 받는다(추가 외부호출 없음).
* 경고는 sms_dispatch 를 거치지 않고 직접 발송·기록하여 재귀를 피한다.
*/
function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
if ($remaining === null || $remaining > SMS_BALANCE_WARN_THRESHOLD) {
return;
}
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
$recent = $db->prepare("
SELECT sent_at FROM leak_sms_log
WHERE message LIKE :tag
ORDER BY sent_at DESC
LIMIT 1
");
$recent->execute([':tag' => SMS_TAG_BALANCE_LOW . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < SMS_BALANCE_WARN_COOLDOWN_SEC) {
return;
}
$message = SMS_TAG_BALANCE_LOW . "\n"
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n"
. "충전하지 않으면 경보 발송이 중단됩니다.\n"
. "시각: " . date('Y-m-d H:i:s');
foreach (SMS_RECIPIENTS as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message);
$stmt = $db->prepare("
INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at)
VALUES
(0, 'SMS잔액', '', :message, :receivers,
:status, :result_code, :result_message, NOW())
");
$stmt->execute([
':message' => $message,
':receivers' => $receiverDigits,
':status' => $result['success'] ? 'success' : 'fail',
':result_code' => $result['result_code'],
':result_message' => $result['message'],
]);
}
error_log("[SMS] 잔액부족 경고 발송 - 잔여 {$remaining}건");
}
/**
* 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
*/
function metric_value_str(float $value): string {
$s = number_format($value, 1, '.', '');
if (strpos($s, '.') !== false) {
$s = rtrim(rtrim($s, '0'), '.');
}
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);
}
/**
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
*/
function condensation_margin_c(?float $temp_c, ?float $rh): ?float {
if ($temp_c === null || $rh === null) {
return null;
}
$td = dew_point_c($temp_c, $rh);
if ($td === null) {
return null;
}
return $temp_c - $td;
}
/**
* 결로 위험이 해소(정상)인지(히스테리시스 적용). 결로 경보 비활성/결측/산출불가는
* 정상으로 간주한다. 복귀 판정에서 사용.
*/
function condensation_is_clear(?float $temp_c, ?float $rh): bool {
if (METRIC_DEWPOINT_MARGIN_C <= 0 || $temp_c === null || $rh === null) {
return true;
}
$margin = condensation_margin_c($temp_c, $rh);
if ($margin === null) {
return true;
}
return $margin > METRIC_DEWPOINT_MARGIN_C + METRIC_DEWPOINT_HYSTERESIS_C;
}
/**
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
*/
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches = [];
if ($temp_c !== null) {
if ($temp_c >= METRIC_TEMP_HIGH_C) {
$breaches[] = 'high_temp';
} elseif ($temp_c <= METRIC_TEMP_LOW_C) {
$breaches[] = 'low_temp';
}
}
if ($rh !== null) {
if ($rh >= METRIC_RH_HIGH) {
$breaches[] = 'high_humidity';
} elseif ($rh <= METRIC_RH_LOW) {
$breaches[] = 'low_humidity';
}
}
// 결로(이슬점) 위험: 온도·습도가 각각 정상이어도 결로마진이 좁으면 발화(말미에 추가
// 하여, 동시 발생 시 대표 status 는 온/습도 임계가 우선하도록 한다).
if (METRIC_DEWPOINT_MARGIN_C > 0 && $temp_c !== null && $rh !== null) {
$margin = condensation_margin_c($temp_c, $rh);
if ($margin !== null && $margin <= METRIC_DEWPOINT_MARGIN_C) {
$breaches[] = 'condensation_risk';
}
}
return [
'breaches' => $breaches,
'status' => empty($breaches) ? 'normal' : $breaches[0],
];
}
/**
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
* 결로 위험도 해소되어야 복귀로 본다. 측정 결측(null)은 해당 지표를 정상으로 간주한다.
*/
function metric_is_recovered(?float $temp_c, ?float $rh): bool {
$tOk = ($temp_c === null) || (
$temp_c < METRIC_TEMP_HIGH_C - METRIC_TEMP_HYSTERESIS_C &&
$temp_c > METRIC_TEMP_LOW_C + METRIC_TEMP_HYSTERESIS_C
);
$hOk = ($rh === null) || (
$rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS &&
$rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS
);
return $tOk && $hOk && condensation_is_clear($temp_c, $rh);
}
/**
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준).
*/
function metric_was_alerting(PDO $db, int $sensor_id): bool {
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
// 결로(결로주의)도 1급 경보이므로 포함해야, 복귀(정상복귀) SMS 1회가 정상 발화한다.
$tags = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP,
SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION,
SMS_TAG_RECOVERY,
];
$likes = [];
$params = [':sid' => $sensor_id];
foreach ($tags as $i => $tag) {
$key = ":t{$i}";
$likes[] = "message LIKE {$key}";
$params[$key] = $tag . '%';
}
$sql = "SELECT message FROM leak_sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC
LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$msg = $stmt->fetchColumn();
if ($msg === false) {
return false;
}
return !str_starts_with($msg, SMS_TAG_RECOVERY);
}
/**
* 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함).
*
* 쿨다운: 같은 종류([고온경보] 등)의 최근 성공 SMS가 METRIC_ALERT_COOLDOWN_SEC
* 이내면 발송을 생략한다(중복 폭주 방지).
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'
* @param float $value 측정값 (온도℃ 또는 습도%)
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
*/
function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
$labels = [
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
];
if (!isset($labels[$kind])) {
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
return false;
}
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
// 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
// 안전하게 폴백한다. (같은 본문 재시도/재생은 sensor_data.php 의 nonce 멱등에서 선차단)
$lockName = substr("sms_alert_{$sensor_id}_{$kind}", 0, 64);
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => $lockName]);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
}
if ($lockState === 'busy') {
error_log("[SMS] {$tag} 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
return false;
}
try {
// ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ──────────────
$recent = $db->prepare("
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at 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)");
return false;
}
// ── 메시지 구성 ──────────────────────────────────────────
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$valStr = metric_value_str($value);
$thrStr = metric_value_str((float)$threshold);
$message = "{$tag}\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage());
}
}
}
}
/**
* 온습도 정상 복귀 SMS 발송.
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @param float $temp_c 현재 온도(℃)
* @param float $rh 현재 습도(%)
* @return bool 발송 성공 여부
*/
function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
. "시각: {$dt}\n"
. "온습도가 정상 범위로 복귀했습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
}
/**
* 누수 감지 SMS 발송 (1번 모듈). 쿨다운 포함.
*
* 쿨다운: 같은 센서의 최근 성공한 [누수감지] SMS가 LEAK_ALERT_COOLDOWN_SEC 이내면
* 발송을 생략한다(누수 지속 중 주기 보고로 인한 중복 폭주 방지).
* 동시 처리 직렬화는 sms_send 의 metric 경보와 동일하게 MySQL 네임드 락으로 보장한다.
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
*/
function send_leak_alert_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// ── 동시 발송 직렬화 (중복 SMS 방지) ──
$lockName = substr("sms_leak_alert_{$sensor_id}", 0, 64);
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => $lockName]);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
}
if ($lockState === 'busy') {
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
return false;
}
try {
// ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ──
$recent = $db->prepare("
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at 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)");
return false;
}
// ── 메시지 구성 ──
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_ALERT . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage());
}
}
}
}
/**
* 누수 복구 SMS 발송 (1번 모듈). 직전에 누수 경보([누수감지])가 있었고 그 뒤 복구
* 통지가 없었을 때만 1회 발송한다(복구만 단독으로 발송되는 것 방지).
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @return bool 발송 성공 여부 (경보 이력 없거나 이미 복구 통지면 false)
*/
function send_leak_recovery_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
$stmt = $db->prepare("
SELECT message FROM leak_sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC
LIMIT 1
");
$stmt->execute([
':sid' => $sensor_id,
':alert' => SMS_TAG_LEAK_ALERT . '%',
':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
]);
$last = $stmt->fetchColumn();
if ($last === false || !str_starts_with((string)$last, SMS_TAG_LEAK_ALERT)) {
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
}
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
}
/**
* 장비 오프라인 SMS 발송
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location','last_seen']
* @return bool 발송 성공 여부
*/
function send_device_offline_sms(array $sensor_data): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$last = $sensor_data['last_seen'] ?? '알 수 없음';
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "마지막 통신: {$last}\n"
. "감지 시각: {$dt}\n"
. "장비 상태를 확인하세요!";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인");
}
/**
* 장비 온라인 복구 SMS 발송
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @return bool 발송 성공 여부
*/
function send_device_online_sms(array $sensor_data): bool {
$db = get_db();
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_DEVICE_ONLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "복구 시각: {$dt}\n"
. "통신이 정상 복구되었습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
}
/**
* 일일 헬스(생존신호) 요약 문자열. 짧게 유지(SMS 단문 길이 고려).
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
*/
function build_health_summary(PDO $db): string {
$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 leak_sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
$alerts = (int)$db->query("
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 . "%'
OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%'
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
")->fetchColumn();
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건";
$bal = latest_sms_balance($db);
if ($bal !== null) {
$summary .= ", 잔여 {$bal}건";
}
return $summary;
}
/**
* 하루 1회 '[정기점검]' 생존신호 SMS 를 보낸다(데드맨). HEALTH_SMS_HOUR 시 이후의
* 첫 호출에서, 오늘자 발송이 없을 때만 발송한다. cron_heartbeat 가 주기적으로 호출.
* 날짜 dedup + 네임드 락으로 중복 발송을 막는다(락 미지원 시 날짜 dedup 만으로 방어).
*/
function maybe_send_daily_health_sms(PDO $db): void {
if (!HEALTH_SMS_ENABLED || (int)date('G') < HEALTH_SMS_HOUR) {
return;
}
$today = $db->prepare("
SELECT 1 FROM leak_sms_log
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
LIMIT 1
");
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
if ($today->fetchColumn() !== false) {
return; // 오늘 이미 발송함
}
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => 'daily_health_sms']);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
}
if ($lockState === 'busy') {
return; // 다른 프로세스가 처리 중
}
try {
// 락 획득 후 이중 확인(획득 사이에 다른 프로세스가 발송했을 수 있음).
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
if ($today->fetchColumn() !== false) {
return;
}
$message = SMS_TAG_HEALTH . "\n" . build_health_summary($db);
$info = ['sensor_id' => 0, 'sensor_name' => '정기점검', 'device_id' => '', 'device_location' => ''];
sms_dispatch_to_recipients($db, $info, $message, "정기점검");
error_log("[SMS] 정기점검(생존신호) 발송");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => 'daily_health_sms']);
} catch (Throwable $e) {
/* 해제 실패 무시 */
}
}
}
}
/**
* 특정 태그의 최근 발송이 $seconds 이내인지(쿨다운 판정). 태그별 1회 발송 억제에 사용.
*/
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
$stmt = $db->prepare("
SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE :tag
ORDER BY sent_at DESC LIMIT 1
");
$stmt->execute([':sid' => $sensor_id, ':tag' => $tag . '%']);
$last = $stmt->fetchColumn();
return $last !== false && (time() - strtotime($last)) < $seconds;
}
/**
* 모집단 표준편차(σ). 측정값 고착(변화 없음) 판정에 사용.
*/
function stddev_pop(array $values): float {
$n = count($values);
if ($n === 0) {
return 0.0;
}
$mean = array_sum($values) / $n;
$sum = 0.0;
foreach ($values as $v) {
$d = (float)$v - $mean;
$sum += $d * $d;
}
return sqrt($sum / $n);
}
/**
* 센서별 최신 경보-상태 SMS 1건(경보/복귀/에스컬레이션 태그 중). 에스컬레이션 판정용.
*/
function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
$tags = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION, SMS_TAG_RECOVERY, SMS_TAG_ESCALATION,
];
$likes = [];
$params = [':sid' => $sensor_id];
foreach ($tags as $i => $t) {
$k = ":t{$i}";
$likes[] = "message LIKE {$k}";
$params[$k] = $t . '%';
}
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
$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";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* 미복귀 임계 경보를 2차(야간) 수신자에게 에스컬레이션한다(에피소드당 1회). 각 센서의
* 최신 경보-상태가 '경보'이고 ESCALATE_AFTER_SEC 이상 지속(미복귀·미에스컬레이션)이면
* SMS_RECIPIENTS_TIER2 로 1회 통지. TIER2 가 비면 비활성(opt-in). cron_heartbeat 가 호출.
*/
function maybe_escalate_alerts(PDO $db): void {
if (empty(SMS_RECIPIENTS_TIER2) || ESCALATE_AFTER_SEC <= 0) {
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 leak_sensor_status")->fetchAll();
foreach ($sensors as $s) {
$sid = (int)$s['sensor_id'];
$row = latest_alert_state_row($db, $sid);
if ($row === null) {
continue;
}
$msg = (string)$row['message'];
// 최신이 복귀/이미 에스컬레이션이면 에피소드 종료/처리됨 → 스킵.
if (str_starts_with($msg, SMS_TAG_RECOVERY) || str_starts_with($msg, SMS_TAG_ESCALATION)) {
continue;
}
$isAlert = false;
foreach ($alertTags as $t) {
if (str_starts_with($msg, $t)) { $isAlert = true; break; }
}
if (!$isAlert || (time() - strtotime((string)$row['sent_at'])) < ESCALATE_AFTER_SEC) {
continue;
}
// 이미 에스컬레이션을 시도했으면(성공/실패 불문) 재발화 금지. latest_alert_state_row
// 는 success 만 보므로, 실패한 에스컬레이션 행이 보이지 않아 매 주기 재발송되는 것을
// 막는다(상태 무관 recent_tag_within 으로 가드 — 에피소드당 1회 보장).
if (recent_tag_within($db, $sid, SMS_TAG_ESCALATION, ESCALATE_AFTER_SEC)) {
continue;
}
$firstLine = strtok($msg, "\n");
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
$message = SMS_TAG_ESCALATION . "\n"
. "위치: {$loc}\n센서: {$name}\n"
. "{$firstLine} 미복귀 {$mins}분 경과\n"
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");
}
}
/**
* 측정값 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 온도·습도 표준편차가
* 둘 다 STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장을 의심해 1회 알린다(쿨다운).
* 하트비트(수신 끊김)로는 못 잡는 무성실패를 보완. cron_heartbeat 가 호출.
*/
function maybe_detect_stuck_sensors(PDO $db): void {
if (!STUCK_DETECT_ENABLED || STUCK_DETECT_SAMPLES < 3) {
return;
}
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
$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 leak_sensor_metric
WHERE sensor_id = :sid
ORDER BY measured_at DESC
LIMIT {$n}
");
$stmt->execute([':sid' => $sid]);
$rows = $stmt->fetchAll();
if (count($rows) < $n) {
continue; // 표본 부족
}
$temps = array_values(array_filter(array_column($rows, 'temperature_c'), fn($v) => $v !== null));
$hums = array_values(array_filter(array_column($rows, 'humidity_percent'), fn($v) => $v !== null));
// 온도·습도 둘 다 충분한 표본이 완전 평탄해야 고착으로 본다(한쪽만 평탄은 정상 가능).
$tStuck = count($temps) >= $n && stddev_pop($temps) < STUCK_DETECT_EPSILON;
$hStuck = count($hums) >= $n && stddev_pop($hums) < STUCK_DETECT_EPSILON;
if (!($tStuck && $hStuck)) {
continue;
}
if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
continue; // 쿨다운
}
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
$tval = number_format((float)$rows[0]['temperature_c'], 2);
$hval = number_format((float)$rows[0]['humidity_percent'], 2);
$message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n센서: {$name}\n"
. "최근 {$n}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n"
. "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
sms_dispatch_to_recipients($db, $info, $message, "센서고착");
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
}
}