Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run
275 lines
11 KiB
Python
275 lines
11 KiB
Python
|
||
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()
|