From 638f6773fb4470466044a79f23137bcfb4140863 Mon Sep 17 00:00:00 2001 From: qorgh529 Date: Mon, 27 Apr 2026 20:30:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=20=EB=8B=A4=EC=A4=91=20=EA=B4=80=EB=A6=AC=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 169 ++++++++++++++++++++++++-------- frontend/index.html | 234 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 309 insertions(+), 94 deletions(-) diff --git a/backend/main.py b/backend/main.py index 927cf20..a9a1525 100755 --- a/backend/main.py +++ b/backend/main.py @@ -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 이상/복구 테스트) diff --git a/frontend/index.html b/frontend/index.html index 27def9c..449a103 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -364,50 +364,87 @@
-
-
-

- Discord - Webhook 설정 -

+
+ + +
+

➕ 채널 추가

+
- - -

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

-
-
-
-

- Gmail - 이메일 설정 -

-
- - + +
- -
- - + + +
+ + +
+
+ Discord +
+
+ +
-

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

-
- - + + +
+
+ Gmail +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+

등록된 채널 목록

+ +
+
+
등록된 채널이 없습니다.
-
- - -
-
+
@@ -960,36 +997,131 @@ 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);} +// ── 알림 채널 관리 ───────────────────────────────────────── +let notifyChannels = []; + +function onNotifyTypeChange(){ + const type = document.getElementById('nc-type').value; + document.getElementById('nc-discord-section').style.display = (type==='email') ? 'none' : ''; + document.getElementById('nc-email-section').style.display = (type==='discord') ? 'none' : ''; } -async function saveNotifyConfig(){ +async function loadNotifyConfig(){ + try{ + notifyChannels = await apiFetch('/admin/notify-channels'); + renderNotifyChannelList(); + document.getElementById('nc-count').textContent = `총 ${notifyChannels.length}개`; + }catch(e){ console.error('알림 채널 로드 실패:', e.message); } +} + +function renderNotifyChannelList(){ + const el = document.getElementById('notify-channel-list'); + if(!notifyChannels.length){ + el.innerHTML = '
등록된 채널이 없습니다.
'; + return; + } + const typeLabel = {both:'Discord+Gmail', discord:'Discord', email:'Gmail'}; + const typeColor = {both:'#7c3aed', discord:'#5865f2', email:'#ea4335'}; + el.innerHTML = notifyChannels.map(ch => ` +
+
+
+ ${escHtml(ch.name)} + ${typeLabel[ch.type]||ch.type} + ${ch.enabled ? '' : '비활성'} +
+
+ ${ch.discord_webhook_url ? '🔗 Webhook 설정됨' : ''} + ${ch.discord_webhook_url && ch.gmail_user ? ' · ' : ''} + ${ch.gmail_user ? `📧 ${escHtml(ch.gmail_user)} → ${escHtml(ch.alert_email_to)}` : ''} +
+
+
+ + +
+
+ `).join(''); +} + +function selectNotifyChannel(id){ + const ch = notifyChannels.find(c => c.id === id); + if(!ch) return; + // 기존 선택 해제 + document.querySelectorAll('[id^=nc-row-]').forEach(el => el.style.background = ''); + document.getElementById('nc-row-'+id).style.background = '#eff6ff'; + + document.getElementById('nc-editing-id').value = ch.id; + document.getElementById('nc-name').value = ch.name; + document.getElementById('nc-type').value = ch.type; + document.getElementById('nc-discord-url').value = ch.discord_webhook_url || ''; + document.getElementById('nc-gmail-user').value = ch.gmail_user || ''; + document.getElementById('nc-gmail-pw').value = ''; // 보안상 비움 + document.getElementById('nc-alert-to').value = ch.alert_email_to || ''; + document.getElementById('nc-enabled').checked = ch.enabled; + document.getElementById('notify-form-title').textContent = '✏️ 채널 편집'; + document.getElementById('nc-save-btn').textContent = '💾 수정 저장'; + document.getElementById('notify-save-msg').textContent = ''; + onNotifyTypeChange(); +} + +function resetNotifyForm(){ + document.getElementById('nc-editing-id').value = ''; + document.getElementById('nc-name').value = ''; + document.getElementById('nc-type').value = 'both'; + document.getElementById('nc-discord-url').value = ''; + document.getElementById('nc-gmail-user').value = ''; + document.getElementById('nc-gmail-pw').value = ''; + document.getElementById('nc-alert-to').value = ''; + document.getElementById('nc-enabled').checked = true; + document.getElementById('notify-form-title').textContent = '➕ 채널 추가'; + document.getElementById('nc-save-btn').textContent = '💾 저장'; + document.getElementById('notify-save-msg').textContent = ''; + document.querySelectorAll('[id^=nc-row-]').forEach(el => el.style.background = ''); + onNotifyTypeChange(); +} + +async function saveNotifyChannel(){ const msgEl = document.getElementById('notify-save-msg'); + const editingId = document.getElementById('nc-editing-id').value; + const name = document.getElementById('nc-name').value.trim(); + if(!name){ msgEl.style.color='var(--danger)'; msgEl.textContent='❌ 채널 이름을 입력하세요.'; return; } + const payload = { + name, + type: document.getElementById('nc-type').value, 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(), + enabled: document.getElementById('nc-enabled').checked, }; try{ - await apiFetch('/admin/notify-config',{method:'PUT',body:JSON.stringify(payload)}); - msgEl.style.color = 'var(--success)'; - msgEl.textContent = '✅ 설정이 저장되었습니다.'; + if(editingId){ + await apiFetch(`/admin/notify-channels/${editingId}`, {method:'PUT', body:JSON.stringify(payload)}); + msgEl.style.color='var(--success)'; msgEl.textContent='✅ 수정되었습니다.'; + } else { + await apiFetch('/admin/notify-channels', {method:'POST', body:JSON.stringify(payload)}); + msgEl.style.color='var(--success)'; msgEl.textContent='✅ 채널이 추가되었습니다.'; + } + await loadNotifyConfig(); + resetNotifyForm(); }catch(e){ - msgEl.style.color = 'var(--danger)'; - msgEl.textContent = '❌ 저장 실패: '+e.message; + msgEl.style.color='var(--danger)'; msgEl.textContent='❌ 저장 실패: '+e.message; } } +async function deleteNotifyChannel(id){ + if(!confirm('이 채널을 삭제하시겠습니까?')) return; + try{ + await apiFetch(`/admin/notify-channels/${id}`, {method:'DELETE'}); + await loadNotifyConfig(); + resetNotifyForm(); + }catch(e){ alert('삭제 실패: '+e.message); } +} + async function testNotify(){ try{ await apiFetch('/admin/notify-test');