solorpower/crawler/alert_manager.py
haneulai a716dbef96
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
feat: harden solar monitoring through stage 7
2026-08-07 14:07:22 +09:00

275 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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()