From c24a2696db12d10ca74ee3c0cb6ae31a121a6d8b Mon Sep 17 00:00:00 2001 From: qorgh529 Date: Mon, 27 Apr 2026 20:17:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 67 ++++++++++++++++++++++++++++++++++ frontend/index.html | 89 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/backend/main.py b/backend/main.py index 70b0936..927cf20 100755 --- a/backend/main.py +++ b/backend/main.py @@ -79,6 +79,11 @@ def init_db(): webpage_id INTEGER REFERENCES webpages(id) ON DELETE CASCADE, PRIMARY KEY (user_id, webpage_id) ); + CREATE TABLE IF NOT EXISTS notify_config ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() + ); """) # 컬럼 추가 (기존 DB 마이그레이션) for col, definition in [ @@ -420,6 +425,68 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad conn.commit() return {"ok": True} +# ─── Admin: 알림 설정 ──────────────────────────────────── +class NotifyConfig(BaseModel): + discord_webhook_url: Optional[str] = "" + gmail_user: Optional[str] = "" + gmail_app_password: Optional[str] = "" + alert_email_to: Optional[str] = "" + +def get_notify_config_from_db(conn) -> dict: + """DB에서 알림 설정 조회, 없으면 환경변수 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} + 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", "")), + } + +@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 모듈 환경변수 동적 업데이트 + 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"] + + return {"ok": True} + @app.get("/api/admin/notify-test") async def notify_test(token=Depends(require_admin)): # Discord + Gmail (Pod 이상/복구 테스트) diff --git a/frontend/index.html b/frontend/index.html index 7182acc..27def9c 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -209,6 +209,7 @@ +
@@ -359,6 +360,56 @@ + + +
+ +
+
+

+ Discord + Webhook 설정 +

+
+ + +

채널 설정 → 연동 → 웹후크에서 URL을 복사하세요.

+
+
+
+

+ Gmail + 이메일 설정 +

+
+ + +
+
+ +
+ + +
+

myaccount.google.com/apppasswords 에서 발급하세요. 변경하지 않을 경우 비워두세요.

+
+
+ + +
+
+
+ + +
+
+
+
@@ -450,6 +501,7 @@ function showPage(name){ if(name==='board')loadBoard(); if(name==='admin-pages')loadAdminPages(); if(name==='admin-users')loadAdminUsers(); + if(name==='admin-notify')loadNotifyConfig(); } function closeModal(id){document.getElementById(id).classList.remove('open');} @@ -908,6 +960,43 @@ async function saveAccess(){ closeModal('access-modal');alert('권한이 저장되었습니다.'); } +// ── 알림 설정 ────────────────────────────────────────────── +async function loadNotifyConfig(){ + try{ + const data = await apiFetch('/admin/notify-config'); + document.getElementById('nc-discord-url').value = data.discord_webhook_url || ''; + document.getElementById('nc-gmail-user').value = data.gmail_user || ''; + document.getElementById('nc-gmail-pw').value = data.gmail_app_password || ''; + document.getElementById('nc-alert-to').value = data.alert_email_to || ''; + document.getElementById('notify-save-msg').textContent = ''; + }catch(e){alert('알림 설정 불러오기 실패: '+e.message);} +} + +async function saveNotifyConfig(){ + const msgEl = document.getElementById('notify-save-msg'); + const payload = { + discord_webhook_url: document.getElementById('nc-discord-url').value.trim(), + gmail_user: document.getElementById('nc-gmail-user').value.trim(), + gmail_app_password: document.getElementById('nc-gmail-pw').value, + alert_email_to: document.getElementById('nc-alert-to').value.trim(), + }; + try{ + await apiFetch('/admin/notify-config',{method:'PUT',body:JSON.stringify(payload)}); + msgEl.style.color = 'var(--success)'; + msgEl.textContent = '✅ 설정이 저장되었습니다.'; + }catch(e){ + msgEl.style.color = 'var(--danger)'; + msgEl.textContent = '❌ 저장 실패: '+e.message; + } +} + +async function testNotify(){ + try{ + await apiFetch('/admin/notify-test'); + alert('✅ 테스트 알림이 발송되었습니다.\nDiscord와 이메일을 확인해주세요.'); + }catch(e){alert('테스트 실패: '+e.message);} +} + // ── Utils ───────────────────────────────────────────────── function escHtml(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function escJs(s){return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");}