feat: 알림 채널 다중 관리 UI 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-27 20:30:16 +09:00
parent c24a2696db
commit 638f6773fb
2 changed files with 309 additions and 94 deletions

View File

@@ -425,59 +425,73 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad
conn.commit()
return {"ok": True}
# ─── Admin: 알림 설정 ────────────────────────────────────
class NotifyConfig(BaseModel):
# ─── Admin: 알림 채널 관리 (다중 채널) ────────────────────
class NotifyChannel(BaseModel):
id: Optional[int] = None
name: str # 채널 이름 (식별용)
type: str # "discord" | "email"
discord_webhook_url: Optional[str] = ""
gmail_user: Optional[str] = ""
gmail_app_password: Optional[str] = ""
alert_email_to: Optional[str] = ""
enabled: Optional[bool] = True
def init_notify_channels_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS notify_channels (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL,
discord_webhook_url TEXT DEFAULT '',
gmail_user VARCHAR(200) DEFAULT '',
gmail_app_password TEXT DEFAULT '',
alert_email_to VARCHAR(200) DEFAULT '',
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_notify_channels():
import time
for _ in range(10):
try:
init_notify_channels_db()
print("Notify channels DB initialized")
break
except Exception as e:
print(f"Notify channels DB not ready... {e}")
time.sleep(3)
def get_notify_config_from_db(conn) -> dict:
"""DB에서 알림 설정 조회, 없으면 환경변수 fallback"""
"""하위 호환: 첫 번째 활성 채널에서 설정 조회, 없으면 환경변수 fallback"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT key, value FROM notify_config")
rows = cur.fetchall()
db_config = {r["key"]: r["value"] for r in rows}
try:
cur.execute("SELECT * FROM notify_channels WHERE enabled=TRUE ORDER BY id LIMIT 1")
row = cur.fetchone()
if row:
return {
"discord_webhook_url": row["discord_webhook_url"] or "",
"gmail_user": row["gmail_user"] or "",
"gmail_app_password": row["gmail_app_password"] or "",
"alert_email_to": row["alert_email_to"] or "",
}
except Exception:
pass
return {
"discord_webhook_url": db_config.get("discord_webhook_url", os.getenv("DISCORD_WEBHOOK_URL", "")),
"gmail_user": db_config.get("gmail_user", os.getenv("GMAIL_USER", "")),
"gmail_app_password": db_config.get("gmail_app_password", os.getenv("GMAIL_APP_PASSWORD", "")),
"alert_email_to": db_config.get("alert_email_to", os.getenv("ALERT_EMAIL_TO", "")),
"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", ""),
}
@app.get("/api/admin/notify-config")
def get_notify_config(token=Depends(require_admin), conn=Depends(get_db)):
cfg = get_notify_config_from_db(conn)
# 비밀번호는 설정 여부만 반환 (보안)
return {
"discord_webhook_url": cfg["discord_webhook_url"],
"gmail_user": cfg["gmail_user"],
"gmail_app_password": "••••••••" if cfg["gmail_app_password"] else "",
"alert_email_to": cfg["alert_email_to"],
}
@app.put("/api/admin/notify-config")
def save_notify_config(data: NotifyConfig, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
fields = {
"discord_webhook_url": data.discord_webhook_url,
"gmail_user": data.gmail_user,
"alert_email_to": data.alert_email_to,
}
# 비밀번호가 마스킹값이면 업데이트 안 함
if data.gmail_app_password and data.gmail_app_password != "••••••••":
fields["gmail_app_password"] = data.gmail_app_password
for key, value in fields.items():
if value is not None:
cur.execute("""
INSERT INTO notify_config (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()
""", (key, value))
conn.commit()
# notifier 모듈 환경변수 동적 업데이트
def refresh_notifier(conn):
"""활성화된 모든 채널 중 첫 번째 채널로 notifier 모듈 업데이트"""
import notifier
cfg = get_notify_config_from_db(conn)
notifier.DISCORD_WEBHOOK_URL = cfg["discord_webhook_url"]
@@ -485,8 +499,77 @@ def save_notify_config(data: NotifyConfig, token=Depends(require_admin), conn=De
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)
cur.execute("SELECT id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled, created_at FROM notify_channels ORDER BY id")
rows = cur.fetchall()
# 비밀번호 제외하고 반환
return [dict(r) for r in rows]
@app.post("/api/admin/notify-channels")
def create_notify_channel(data: NotifyChannel, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""
INSERT INTO notify_channels (name, type, discord_webhook_url, gmail_user, gmail_app_password, alert_email_to, enabled)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (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}")
def update_notify_channel(channel_id: int, data: NotifyChannel, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# 비밀번호가 비어있으면 기존값 유지
if data.gmail_app_password and data.gmail_app_password.strip():
cur.execute("""
UPDATE notify_channels SET name=%s, type=%s, discord_webhook_url=%s,
gmail_user=%s, gmail_app_password=%s, alert_email_to=%s, enabled=%s
WHERE id=%s RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (data.name, data.type, data.discord_webhook_url or "", data.gmail_user or "",
data.gmail_app_password, data.alert_email_to or "", data.enabled, channel_id))
else:
cur.execute("""
UPDATE notify_channels SET name=%s, type=%s, discord_webhook_url=%s,
gmail_user=%s, alert_email_to=%s, enabled=%s
WHERE id=%s RETURNING id, name, type, discord_webhook_url, gmail_user, alert_email_to, enabled
""", (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}")
def delete_notify_channel(channel_id: int, token=Depends(require_admin), conn=Depends(get_db)):
cur = conn.cursor()
cur.execute("DELETE FROM notify_channels WHERE id=%s", (channel_id,))
conn.commit()
refresh_notifier(conn)
return {"ok": True}
# ─── 하위호환: 기존 단일 설정 API ────────────────────────
def get_notify_config_from_db_legacy(conn) -> dict:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
try:
cur.execute("SELECT key, value FROM notify_config")
rows = cur.fetchall()
db_config = {r["key"]: r["value"] for r in rows}
return {
"discord_webhook_url": db_config.get("discord_webhook_url", os.getenv("DISCORD_WEBHOOK_URL", "")),
"gmail_user": db_config.get("gmail_user", os.getenv("GMAIL_USER", "")),
"gmail_app_password": db_config.get("gmail_app_password", os.getenv("GMAIL_APP_PASSWORD", "")),
"alert_email_to": db_config.get("alert_email_to", os.getenv("ALERT_EMAIL_TO", "")),
}
except Exception:
return {
"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", ""),
}
@app.get("/api/admin/notify-test")
async def notify_test(token=Depends(require_admin)):
# Discord + Gmail (Pod 이상/복구 테스트)