query(" SELECT sensor_id, sensor_name, device_id, device_location, last_seen, offline_alerted, TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS silent_sec FROM leak_sensor_status ")->fetchAll(); // 각 센서를 SMS 발송 전에 "원자적으로 선점"한다(0→1 전이가 한 프로세스에서만 // 성공). 여러 진입점(sensor_data 쓰로틀, 로그인 시 즉시 체크, 대시보드)이 // 동시에 실행돼도 오프라인 SMS 가 중복 발송되지 않도록 한다. // last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면 // (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다. // 타임아웃은 센서별로 바인딩한다. $claim = $db->prepare(" 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) "); $count = 0; foreach ($rows as $sensor) { if ((int)$sensor['offline_alerted'] !== 0) { continue; // 이미 오프라인 알림 발송됨 } $sid = (int)$sensor['sensor_id']; $timeout = heartbeat_timeout_for($sid); if ($sensor['silent_sec'] === null || (int)$sensor['silent_sec'] < $timeout) { continue; // 이 센서의 타임아웃 이내 → 온라인 } $claim->execute([ ':sensor_id' => $sid, ':timeout' => $timeout, ]); if ($claim->rowCount() === 0) { // 다른 동시 실행이 이미 선점했거나, 그 사이 장비가 복귀 보고를 했다. continue; } $sensor_info = [ 'sensor_id' => $sensor['sensor_id'], 'sensor_name' => $sensor['sensor_name'], 'device_id' => $sensor['device_id'], 'device_location' => $sensor['device_location'], 'last_seen' => $sensor['last_seen'], ]; $sent = send_device_offline_sms($sensor_info); if (!$sent && SMS_RETRY_OFFLINE) { // 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에 // 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭). $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']} 재시도 예약"); } $count++; error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})"); } if ($count > 0) { error_log("[Heartbeat] " . date('Y-m-d H:i:s') . " - 오프라인 감지: {$count}건"); } return $count; } /** * 쓰로틀 기반 오프라인 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 간격으로 1회만 실행) * sensor_data.php, dashboard.php 등에서 호출 */ function check_offline_sensors_throttled(): void { $lock_file = __DIR__ . '/var/heartbeat_last.txt'; // 주기 판정을 원자적으로: 락을 쥔 채 마지막 실행 시각을 읽고, 주기가 도래했으면 // 타임스탬프를 먼저 갱신한 뒤 락을 풀고 실행한다. 이렇게 하면 동시 호출(여러 // 디바이스 POST / 대시보드 로드)이 몰려도 한 주기에 단 한 번만 스캔이 돈다. // (기존 비원자 read-then-write 는 동시 호출이 모두 stale 로 읽혀 중복 스캔됐다.) $fh = @fopen($lock_file, 'c+'); if ($fh === false) { // 파일을 못 열면 가용성 우선: 잠금 없이 한 번 실행. try { check_offline_sensors(); } catch (\Throwable $e) { error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); } return; } $should_run = false; if (flock($fh, LOCK_EX)) { $raw = stream_get_contents($fh); $last = ($raw !== false && $raw !== '') ? (int)$raw : 0; if ($last === 0 || (time() - $last) >= HEARTBEAT_CHECK_INTERVAL_SEC) { ftruncate($fh, 0); rewind($fh); fwrite($fh, (string)time()); fflush($fh); $should_run = true; } flock($fh, LOCK_UN); } fclose($fh); if ($should_run) { try { // 스캔(DB 작업)은 파일 락을 푼 뒤 실행해 락 보유 시간을 최소화한다. check_offline_sensors(); } catch (\Throwable $e) { // heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨 error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); } // 일일 헬스(생존신호) SMS — 하루 1회 자체 dedup. 실패해도 호출자에 영향 없음. try { maybe_send_daily_health_sms(get_db()); } catch (\Throwable $e) { error_log("[Heartbeat] 헬스 SMS 실패: " . $e->getMessage()); } // 경보 신뢰성: 미복귀 에스컬레이션(opt-in) + 측정값 고착 검출(각자 쿨다운). try { maybe_escalate_alerts(get_db()); maybe_detect_stuck_sensors(get_db()); } catch (\Throwable $e) { error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage()); } // 누수 디바운스: 보고가 끊겨도 미확정 전이(복구/감지)를 시간 경과로 확정 해소. try { sweep_pending_leak_transitions(get_db()); } catch (\Throwable $e) { error_log("[Heartbeat] 누수 디바운스 sweep 실패: " . $e->getMessage()); } } } // ── 독립 실행 시에만 아래 코드 동작 ────────────────────────── // require_once로 불러올 때는 함수 정의만 로드됨 $_is_direct = (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)); if ($_is_direct) { $is_cli = (php_sapi_name() === 'cli'); if (!$is_cli) { // 기본 API_KEY 미교체 상태에서 HTTP 트리거를 허용하면 CRON_SECRET도 예측 // 가능한 기본값(hash_hmac('sha256','cron','change-this-api-key'))이 되므로, // 신규 설치에서 인증 전 SMS/DB 부작용이 발생하는 것을 차단한다. if (api_key_is_default()) { http_response_code(503); header('Content-Type: text/plain; charset=utf-8'); echo 'server not configured'; exit; } $key = $_GET['key'] ?? ''; if (!hash_equals(CRON_SECRET, $key)) { http_response_code(403); header('Content-Type: text/plain; charset=utf-8'); echo 'Forbidden'; exit; } } $count = check_offline_sensors(); try { maybe_send_daily_health_sms(get_db()); maybe_escalate_alerts(get_db()); maybe_detect_stuck_sensors(get_db()); sweep_pending_leak_transitions(get_db()); } catch (\Throwable $e) { error_log("[Heartbeat] 헬스/에스컬레이션/고착/디바운스 실패: " . $e->getMessage()); } if (!$is_cli) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'status' => 'ok', 'offline_count' => $count, 'checked_at' => date('Y-m-d H:i:s'), ], JSON_UNESCAPED_UNICODE); } }