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", ""),
}
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")
def list_notify_channels(token=Depends(require_admin), conn=Depends(get_db)):
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.gmail_app_password or "", data.alert_email_to or "", data.enabled))
conn.commit()
refresh_notifier(conn)
return cur.fetchone()
@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.alert_email_to or "", data.enabled, channel_id))
conn.commit()
refresh_notifier(conn)
return cur.fetchone()
@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.execute("DELETE FROM notify_channels WHERE id=%s", (channel_id,))
conn.commit()
refresh_notifier(conn)
return {"ok": True}
# ─── 하위호환: 기존 단일 설정 API ────────────────────────

View File

@@ -1,20 +1,56 @@
import os
import smtplib
import httpx
import asyncio
import psycopg2
import psycopg2.extras
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
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", "")
GMAIL_USER = os.getenv("GMAIL_USER", "")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "")
ALERT_EMAIL_TO = os.getenv("ALERT_EMAIL_TO", "")
# ── Discord ───────────────────────────────────────────────
async def send_discord(title: str, message: str, color: int = 0xe74c3c):
if not DISCORD_WEBHOOK_URL:
print("[NOTIFIER] Discord webhook URL not set, skipping")
# ── DB에서 활성 채널 목록 조회 ────────────────────────────
def get_active_channels():
try:
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
payload = {
"embeds": [{
@@ -27,7 +63,7 @@ async def send_discord(title: str, message: str, color: int = 0xe74c3c):
}
try:
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):
print(f"[NOTIFIER] Discord error: {res.status_code} {res.text}")
else:
@@ -35,17 +71,17 @@ async def send_discord(title: str, message: str, color: int = 0xe74c3c):
except Exception as e:
print(f"[NOTIFIER] Discord exception: {e}")
# ── Gmail ─────────────────────────────────────────────────
def send_email(subject: str, body: str):
if not all([GMAIL_USER, GMAIL_APP_PASSWORD, ALERT_EMAIL_TO]):
# ── Gmail 단건 발송 ───────────────────────────────────────
def _send_email(gmail_user: str, gmail_app_password: str, alert_email_to: str,
subject: str, body: str):
if not all([gmail_user, gmail_app_password, alert_email_to]):
print("[NOTIFIER] Gmail config not set, skipping")
return
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Web Portal] {subject}"
msg["From"] = GMAIL_USER
msg["To"] = ALERT_EMAIL_TO
msg["From"] = gmail_user
msg["To"] = alert_email_to
html = f"""
<html><body style="font-family:sans-serif;padding:20px">
<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"))
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:
smtp.login(GMAIL_USER, GMAIL_APP_PASSWORD)
smtp.sendmail(GMAIL_USER, ALERT_EMAIL_TO, msg.as_string())
smtp.login(gmail_user, gmail_app_password)
smtp.sendmail(gmail_user, alert_email_to, msg.as_string())
print(f"[NOTIFIER] Email sent: {subject}")
except Exception as 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")
send_discord_flag = (
mode in ("both", "discord") and
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()
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 이상/복구)"""
await send_discord(title, message, color)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, send_email, title, message)
"""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):
"""Gmail만 송 (인증서 만료 임박)"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, send_email, title, message)
"""Gmail 타입 채널에송 (인증서 만료 임박)"""
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="email")
async def notify_discord_only(title: str, message: str, color: int = 0xe74c3c):
"""Discord만 송 (계정 잠금, 임시 비밀번호 발급)"""
await send_discord(title, message, color)
"""Discord 타입 채널에송 (계정 잠금, 임시 비밀번호 발급)"""
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")