import sqlite3 from contextlib import closing import requests from pathlib import Path from config import TELEGRAM_BOT_TOKEN from time_utils import ensure_kst, now_kst class AlertManager: """ 발전소 이상 감지 및 텔레그램 알림 관리 - 상태(정상/이상)를 DB에 저장하여 중복 알림 방지 """ def __init__(self, db_path: str = None, now_provider=None): """ DB 연결 및 테이블 초기화 """ if db_path is None: # crawler_manager와 같은 DB 파일 사용 db_path = Path(__file__).parent / "crawler_manager.db" self.db_path = str(db_path) self.zero_threshold = 3 self.today_growth_threshold = 0.5 self.now_provider = now_provider or now_kst self._alerts_enabled_cache = {} self._init_db() def _init_db(self): """알림 히스토리 테이블 생성""" with closing(sqlite3.connect(self.db_path)) as conn: cursor = conn.cursor() # site_id: 발전소 ID # alert_status: 'NORMAL' (정상), 'ALERT' (이상 발생 및 알림 전송됨) # last_alert_time: 마지막 알림 전송 시간 cursor.execute(""" CREATE TABLE IF NOT EXISTS alert_history ( site_id TEXT PRIMARY KEY, alert_status TEXT DEFAULT 'NORMAL', last_alert_time TEXT, zero_count INTEGER DEFAULT 0, first_zero_today_kwh REAL ) """) cursor.execute("PRAGMA table_info(alert_history)") columns = {row[1] for row in cursor.fetchall()} if 'zero_count' not in columns: cursor.execute("ALTER TABLE alert_history ADD COLUMN zero_count INTEGER DEFAULT 0") if 'first_zero_today_kwh' not in columns: cursor.execute("ALTER TABLE alert_history ADD COLUMN first_zero_today_kwh REAL") conn.commit() def _reset_pending_zero(self, site_id: str): """수집 실패가 실제 0kW 연속 판정에 섞이지 않도록 의심 카운트를 초기화한다.""" with closing(sqlite3.connect(self.db_path)) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE alert_history SET zero_count = 0, first_zero_today_kwh = NULL WHERE site_id = ? AND (zero_count != 0 OR first_zero_today_kwh IS NOT NULL) """, (site_id,)) conn.commit() def _is_alert_enabled(self, site_id: str, plant_name: str) -> bool: """ 발전소별 알림 활성화 설정을 조회한다. plants.id가 전역 고유 기본키이므로 타입이 다른 crawler company_id는 조회 조건에 사용하지 않는다. 일시적인 DB 조회 실패 시에는 마지막 성공 값을 사용하고, 캐시가 없으면 기존 동작과 동일하게 알림 활성 상태로 간주한다. """ try: from database import get_supabase_client client = get_supabase_client() if not client: return self._alerts_enabled_cache.get(site_id, True) response = client.table("plants") \ .select("alerts_enabled") \ .eq("id", site_id) \ .limit(1) \ .execute() if response.data: enabled = response.data[0].get('alerts_enabled') is not False self._alerts_enabled_cache[site_id] = enabled return enabled print(f" ⚠️ [Alert] {plant_name}: 발전소 알림 설정을 찾지 못했습니다.") except Exception as e: print(f" ⚠️ [Alert] {plant_name}: 알림 설정 조회 실패, 마지막 설정을 사용합니다: {e}") return self._alerts_enabled_cache.get(site_id, True) def send_telegram_message(self, chat_id, message): """텔레그램 메시지 전송""" if not TELEGRAM_BOT_TOKEN: print(" ⚠️ 텔레그램 토큰이 설정되지 않았습니다.") return False if not chat_id: # Chat ID가 설정되지 않은 경우 조용히 리턴 (로그는 호출부에서 처리) return False url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" try: payload = {"chat_id": chat_id, "text": message} response = requests.post(url, json=payload, timeout=15) if response.status_code == 200: print(f" 🔔 텔레그램 알림 전송 성공") return True else: print(f" ❌ 텔레그램 전송 실패 ({response.status_code}): {response.text}") return False except Exception as e: print(f" ❌ 텔레그램 전송 중 에러: {e}") return False def check_and_alert( self, plant_info: dict, current_kw: float, today_kwh: float = None, data_valid: bool = True ): """ 발전량을 체크하고 필요 시 알림 전송 - 오전 10시 ~ 오후 5시에만 동작 - current_kw 0이 3회 연속이고 누적 발전량도 정체된 경우만 알림 - 상태 변경 시에만 알림 (중복 방지) """ now = ensure_kst(self.now_provider()) site_id = plant_info.get('id') plant_name = plant_info.get('display_name', plant_info.get('name')) chat_id = plant_info.get('telegram_chat_id') if not site_id: return # 수집 오류/미수집은 발전소의 실제 0kW가 아니다. # 이전 정상 응답에서 시작된 의심 카운트도 끊어 연속 판정에 섞이지 않게 한다. if not data_valid: self._reset_pending_zero(site_id) print(f" ℹ️ [Alert] {plant_name}: 수집 실패 데이터는 0kW 판정에서 제외합니다.") return # 알림 전송 시간대: 오전 10시 ~ 오후 5시 if not (10 <= now.hour <= 17): return if not self._is_alert_enabled(site_id, plant_name): print(f" 🔇 [Alert] {plant_name}: 알림이 비활성화되어 있습니다.") return try: current_kw = float(current_kw or 0) except (TypeError, ValueError): current_kw = 0.0 if today_kwh is not None: try: today_kwh = float(today_kwh) except (TypeError, ValueError): today_kwh = None # 2. 현재 DB 상태 확인 current_status = 'NORMAL' current_last_alert_time = None zero_count = 0 first_zero_today_kwh = None with closing(sqlite3.connect(self.db_path)) as conn: cursor = conn.cursor() cursor.execute(""" SELECT alert_status, last_alert_time, zero_count, first_zero_today_kwh FROM alert_history WHERE site_id = ? """, (site_id,)) row = cursor.fetchone() if row: current_status = row[0] current_last_alert_time = row[1] zero_count = row[2] or 0 first_zero_today_kwh = row[3] else: # 초기값 생성 cursor.execute("INSERT INTO alert_history (site_id, alert_status) VALUES (?, ?)", (site_id, 'NORMAL')) conn.commit() # 3. 상태 전이 로직 new_status = current_status new_last_alert_time = current_last_alert_time new_zero_count = zero_count new_first_zero_today_kwh = first_zero_today_kwh # [Case A] 발전량 0 (이상 감지) if current_kw == 0: new_zero_count += 1 if new_zero_count == 1 or new_first_zero_today_kwh is None: new_first_zero_today_kwh = today_kwh today_delta = None if today_kwh is not None and new_first_zero_today_kwh is not None: today_delta = today_kwh - new_first_zero_today_kwh if today_delta is not None and today_delta > self.today_growth_threshold: print( f" ✅ [Alert] {plant_name}: 0kW 감지됐지만 누적 발전량 증가 " f"({today_delta:.1f}kWh)로 오탐 처리" ) new_zero_count = 0 new_first_zero_today_kwh = None if current_status == 'ALERT': new_status = 'NORMAL' elif new_zero_count < self.zero_threshold: print( f" 🕒 [Alert] {plant_name}: 0kW 의심 " f"{new_zero_count}/{self.zero_threshold}회, 알림 보류" ) elif current_status == 'NORMAL': # NORMAL -> ALERT: 알림 전송 print(f" 🚨 [Alert] {plant_name} 발전량 0kW {new_zero_count}회 연속 감지! 알림 전송 시도...") if chat_id: message = ( f"🚨 [긴급] 발전소 이상 감지!\n\n" f"- 발전소: {plant_name}\n" f"- 상태: 발전량 0kW {new_zero_count}회 연속\n" f"- 시간: {now.strftime('%Y-%m-%d %H:%M:%S')}" ) if self.send_telegram_message(chat_id, message): new_status = 'ALERT' new_last_alert_time = now.isoformat() else: print(f" ⚠️ {plant_name}: Chat ID 오류로 알림 실패") # 전송 실패해도 상태를 ALERT로 할 것인가? # 실패했다면 다음에 다시 시도해야 하므로 NORMAL 유지 else: print(f" ⚠️ {plant_name}: 설정된 Chat ID가 없습니다. (config.py 확인)") # [Case B] 발전량 > 0 (정상 복구) else: new_zero_count = 0 new_first_zero_today_kwh = None if current_status == 'ALERT': # ALERT -> NORMAL: 상태 리셋 print(f" ✅ [Alert] {plant_name} 정상 복구됨 ({current_kw}kW)") # 복구 알림은 옵션 (현재는 생략) new_status = 'NORMAL' new_last_alert_time = now.isoformat() # 4. 상태/의심 카운트 변경 시 DB 업데이트 if ( new_status != current_status or new_zero_count != zero_count or new_first_zero_today_kwh != first_zero_today_kwh ): with closing(sqlite3.connect(self.db_path)) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE alert_history SET alert_status = ?, last_alert_time = ?, zero_count = ?, first_zero_today_kwh = ? WHERE site_id = ? """, (new_status, new_last_alert_time, new_zero_count, new_first_zero_today_kwh, site_id)) conn.commit()