feat: 알림 채널 B방식 다중 발송 적용
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-27 20:46:28 +09:00
parent 638f6773fb
commit 8774bbf128
2 changed files with 97 additions and 37 deletions

View File

@@ -490,15 +490,6 @@ def get_notify_config_from_db(conn) -> dict:
"alert_email_to": os.getenv("ALERT_EMAIL_TO", ""), "alert_email_to": os.getenv("ALERT_EMAIL_TO", ""),
} }
def refresh_notifier(conn):
"""활성화된 모든 채널 중 첫 번째 채널로 notifier 모듈 업데이트"""
import notifier
cfg = get_notify_config_from_db(conn)
notifier.DISCORD_WEBHOOK_URL = cfg["discord_webhook_url"]
notifier.GMAIL_USER = cfg["gmail_user"]
notifier.GMAIL_APP_PASSWORD = cfg["gmail_app_password"]
notifier.ALERT_EMAIL_TO = cfg["alert_email_to"]
@app.get("/api/admin/notify-channels") @app.get("/api/admin/notify-channels")
def list_notify_channels(token=Depends(require_admin), conn=Depends(get_db)): def list_notify_channels(token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
@@ -516,7 +507,6 @@ def create_notify_channel(data: NotifyChannel, token=Depends(require_admin), con
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "", """, (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.gmail_app_password or "", data.alert_email_to or "", data.enabled)) data.gmail_app_password or "", data.alert_email_to or "", data.enabled))
conn.commit() conn.commit()
refresh_notifier(conn)
return cur.fetchone() return cur.fetchone()
@app.put("/api/admin/notify-channels/{channel_id}") @app.put("/api/admin/notify-channels/{channel_id}")
@@ -538,7 +528,6 @@ def update_notify_channel(channel_id: int, data: NotifyChannel, token=Depends(re
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "", """, (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.alert_email_to or "", data.enabled, channel_id)) data.alert_email_to or "", data.enabled, channel_id))
conn.commit() conn.commit()
refresh_notifier(conn)
return cur.fetchone() return cur.fetchone()
@app.delete("/api/admin/notify-channels/{channel_id}") @app.delete("/api/admin/notify-channels/{channel_id}")
@@ -546,7 +535,6 @@ def delete_notify_channel(channel_id: int, token=Depends(require_admin), conn=De
cur = conn.cursor() cur = conn.cursor()
cur.execute("DELETE FROM notify_channels WHERE id=%s", (channel_id,)) cur.execute("DELETE FROM notify_channels WHERE id=%s", (channel_id,))
conn.commit() conn.commit()
refresh_notifier(conn)
return {"ok": True} return {"ok": True}
# ─── 하위호환: 기존 단일 설정 API ──────────────────────── # ─── 하위호환: 기존 단일 설정 API ────────────────────────

View File

@@ -1,20 +1,56 @@
import os import os
import smtplib import smtplib
import httpx import httpx
import asyncio
import psycopg2
import psycopg2.extras
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from datetime import datetime from datetime import datetime
# ── 환경변수 ────────────────────────────────────────────── # ── DB 설정 ───────────────────────────────────────────────
DB_CONFIG = {
"host": os.getenv("DB_HOST", "postgres-service"),
"port": int(os.getenv("DB_PORT", "5432")),
"database": os.getenv("DB_NAME", "portaldb"),
"user": os.getenv("DB_USER", "portaluser"),
"password": os.getenv("DB_PASSWORD", "portalpass"),
}
# ── 환경변수 fallback ─────────────────────────────────────
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "") DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
GMAIL_USER = os.getenv("GMAIL_USER", "") GMAIL_USER = os.getenv("GMAIL_USER", "")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "") GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "")
ALERT_EMAIL_TO = os.getenv("ALERT_EMAIL_TO", "") ALERT_EMAIL_TO = os.getenv("ALERT_EMAIL_TO", "")
# ── Discord ─────────────────────────────────────────────── # ── DB에서 활성 채널 목록 조회 ────────────────────────────
async def send_discord(title: str, message: str, color: int = 0xe74c3c): def get_active_channels():
if not DISCORD_WEBHOOK_URL: try:
print("[NOTIFIER] Discord webhook URL not set, skipping") conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM notify_channels WHERE enabled=TRUE ORDER BY id")
rows = cur.fetchall()
conn.close()
if rows:
return [dict(r) for r in rows]
except Exception as e:
print(f"[NOTIFIER] DB channel load failed: {e}")
# fallback: 환경변수로 단일 채널 구성
if DISCORD_WEBHOOK_URL or GMAIL_USER:
return [{
"id": 0, "name": "default", "type": "both",
"discord_webhook_url": DISCORD_WEBHOOK_URL,
"gmail_user": GMAIL_USER,
"gmail_app_password": GMAIL_APP_PASSWORD,
"alert_email_to": ALERT_EMAIL_TO,
"enabled": True,
}]
return []
# ── Discord 단건 발송 ─────────────────────────────────────
async def _send_discord(webhook_url: str, title: str, message: str, color: int):
if not webhook_url:
return return
payload = { payload = {
"embeds": [{ "embeds": [{
@@ -27,7 +63,7 @@ async def send_discord(title: str, message: str, color: int = 0xe74c3c):
} }
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
res = await client.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) res = await client.post(webhook_url, json=payload, timeout=10)
if res.status_code not in (200, 204): if res.status_code not in (200, 204):
print(f"[NOTIFIER] Discord error: {res.status_code} {res.text}") print(f"[NOTIFIER] Discord error: {res.status_code} {res.text}")
else: else:
@@ -35,17 +71,17 @@ async def send_discord(title: str, message: str, color: int = 0xe74c3c):
except Exception as e: except Exception as e:
print(f"[NOTIFIER] Discord exception: {e}") print(f"[NOTIFIER] Discord exception: {e}")
# ── Gmail ───────────────────────────────────────────────── # ── Gmail 단건 발송 ───────────────────────────────────────
def send_email(subject: str, body: str): def _send_email(gmail_user: str, gmail_app_password: str, alert_email_to: str,
if not all([GMAIL_USER, GMAIL_APP_PASSWORD, ALERT_EMAIL_TO]): subject: str, body: str):
if not all([gmail_user, gmail_app_password, alert_email_to]):
print("[NOTIFIER] Gmail config not set, skipping") print("[NOTIFIER] Gmail config not set, skipping")
return return
try: try:
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Web Portal] {subject}" msg["Subject"] = f"[Web Portal] {subject}"
msg["From"] = GMAIL_USER msg["From"] = gmail_user
msg["To"] = ALERT_EMAIL_TO msg["To"] = alert_email_to
html = f""" html = f"""
<html><body style="font-family:sans-serif;padding:20px"> <html><body style="font-family:sans-serif;padding:20px">
<div style="background:#e74c3c;color:white;padding:12px 20px;border-radius:8px 8px 0 0"> <div style="background:#e74c3c;color:white;padding:12px 20px;border-radius:8px 8px 0 0">
@@ -60,26 +96,62 @@ def send_email(subject: str, body: str):
""" """
msg.attach(MIMEText(html, "html")) msg.attach(MIMEText(html, "html"))
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp: with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
smtp.login(GMAIL_USER, GMAIL_APP_PASSWORD) smtp.login(gmail_user, gmail_app_password)
smtp.sendmail(GMAIL_USER, ALERT_EMAIL_TO, msg.as_string()) smtp.sendmail(gmail_user, alert_email_to, msg.as_string())
print(f"[NOTIFIER] Email sent: {subject}") print(f"[NOTIFIER] Email sent: {subject}")
except Exception as e: except Exception as e:
print(f"[NOTIFIER] Email exception: {e}") print(f"[NOTIFIER] Email exception: {e}")
# ── 채널별 알림 함수 ────────────────────────────────────── # ── 채널 유형별 발송 로직 ─────────────────────────────────
import asyncio async def _dispatch_channel(ch: dict, title: str, message: str, color: int, mode: str):
ch_type = ch.get("type", "both")
async def notify_both(title: str, message: str, color: int = 0xe74c3c): send_discord_flag = (
"""Discord + Gmail 둘 다 전송 (Pod 이상/복구)""" mode in ("both", "discord") and
await send_discord(title, message, color) ch_type in ("both", "discord") and
ch.get("discord_webhook_url")
)
send_email_flag = (
mode in ("both", "email") and
ch_type in ("both", "email") and
ch.get("gmail_user")
)
if send_discord_flag:
await _send_discord(ch["discord_webhook_url"], title, message, color)
if send_email_flag:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, send_email, title, message) await loop.run_in_executor(
None, _send_email,
ch["gmail_user"], ch["gmail_app_password"],
ch["alert_email_to"], title, message
)
# ── 공개 알림 함수 ────────────────────────────────────────
async def notify_both(title: str, message: str, color: int = 0xe74c3c):
"""Discord + Gmail 타입 채널 모두에 발송 (Pod 이상/복구)"""
channels = get_active_channels()
if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="both")
async def notify_email_only(title: str, message: str, color: int = 0xf39c12): async def notify_email_only(title: str, message: str, color: int = 0xf39c12):
"""Gmail만 송 (인증서 만료 임박)""" """Gmail 타입 채널에송 (인증서 만료 임박)"""
loop = asyncio.get_event_loop() channels = get_active_channels()
await loop.run_in_executor(None, send_email, title, message) if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="email")
async def notify_discord_only(title: str, message: str, color: int = 0xe74c3c): async def notify_discord_only(title: str, message: str, color: int = 0xe74c3c):
"""Discord만 송 (계정 잠금, 임시 비밀번호 발급)""" """Discord 타입 채널에송 (계정 잠금, 임시 비밀번호 발급)"""
await send_discord(title, message, color) channels = get_active_channels()
if not channels:
print("[NOTIFIER] No active channels, skipping")
return
for ch in channels:
await _dispatch_channel(ch, title, message, color, mode="discord")