feat: 알림 채널 B방식 다중 발송 적용
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled
This commit is contained in:
@@ -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 ────────────────────────
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
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):
|
async def notify_both(title: str, message: str, color: int = 0xe74c3c):
|
||||||
"""Discord + Gmail 둘 다 전송 (Pod 이상/복구)"""
|
"""Discord + Gmail 타입 채널 모두에 발송 (Pod 이상/복구)"""
|
||||||
await send_discord(title, message, color)
|
channels = get_active_channels()
|
||||||
loop = asyncio.get_event_loop()
|
if not channels:
|
||||||
await loop.run_in_executor(None, send_email, title, message)
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user