"""系统设置 API:通知配置(SMTP + Feishu webhook)。""" from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel, EmailStr, Field from app.deps import AdminUser, DBSession from app.models.setting import Setting from app.utils.crypto import decrypt_dict, encrypt_dict router = APIRouter(prefix="/settings", tags=["settings"]) # Settings key _NOTIFY_KEY = "notifications" # 脱敏字段 _SENSITIVE = {"password", "feishu_webhook_url"} def _redact(d: dict) -> dict: return {k: ("***" if k in _SENSITIVE and v else v) for k, v in d.items()} def _unredact(stored: dict, incoming: dict) -> dict: """编辑时:若 incoming 中脱敏字段为 '***',保留 stored 原值。""" for k in _SENSITIVE: if incoming.get(k) == "***" and k in stored: incoming[k] = stored[k] return incoming def _load(db, key: str) -> dict: row = db.get(Setting, key) if not row: return {} try: return decrypt_dict(row.value) except Exception: return {} def _save(db, key: str, value: str) -> None: row = db.get(Setting, key) if row: row.value = value else: db.add(Setting(key=key, value=value)) db.commit() class SmtpConfig(BaseModel): host: str = "" port: int = 587 user: str = "" password: str = "" from_addr: str = "" use_tls: bool = True class NotificationsConfig(BaseModel): email_enabled: bool = False smtp: SmtpConfig = Field(default_factory=SmtpConfig) email_to: list[str] = Field(default_factory=list) feishu_enabled: bool = False feishu_webhook_url: str = "" class TestRequest(BaseModel): channel: str = Field(..., pattern="^(email|feishu)$") to: str | None = None # 仅 email 通道有效 class TestResponse(BaseModel): ok: bool message: str def _public_view(stored: dict) -> dict: """返回给前端:密码脱敏。""" out = dict(stored) if "smtp" in out and isinstance(out["smtp"], dict): out["smtp"] = _redact(out["smtp"]) if _SENSITIVE.intersection(out.keys()): out = _redact(out) return out @router.get("/notifications", response_model=dict) def get_notifications(db: DBSession, _: AdminUser) -> dict: stored = _load(db, _NOTIFY_KEY) return _public_view(stored) @router.put("/notifications", response_model=dict) def put_notifications(payload: NotificationsConfig, db: DBSession, _: AdminUser) -> dict: stored = _load(db, _NOTIFY_KEY) incoming = payload.model_dump() # 处理 smtp.password 脱敏占位 smtp_in = incoming.get("smtp") or {} smtp_stored = stored.get("smtp") or {} if smtp_in.get("password") == "***" and smtp_stored.get("password"): smtp_in["password"] = smtp_stored["password"] incoming["smtp"] = smtp_in # 处理 webhook_url 脱敏 if incoming.get("feishu_webhook_url") == "***": incoming["feishu_webhook_url"] = stored.get("feishu_webhook_url", "") _save(db, _NOTIFY_KEY, encrypt_dict(incoming)) return _public_view(incoming) @router.post("/notifications/test", response_model=TestResponse) async def test_notification(payload: TestRequest, db: DBSession, _: AdminUser) -> TestResponse: from app.core.notify import NotificationContext, send_email, send_feishu stored = _load(db, _NOTIFY_KEY) if not stored: raise HTTPException(status_code=400, detail="通知配置未设置") ctx = NotificationContext( job_name="test-job", job_type="mysql", status="success", run_id=0, artifact_size=12345, duration_seconds=3, started_at="2026-06-22T10:00:00Z", finished_at="2026-06-22T10:00:03Z", ) if payload.channel == "email": if not stored.get("email_enabled"): raise HTTPException(status_code=400, detail="邮件通知未启用") to_list = [payload.to] if payload.to else stored.get("email_to") or [] ok, msg = await send_email( stored.get("smtp") or {}, to_list, "[备份系统] 测试邮件", "这是一封来自备份管理系统的测试邮件。", ) return TestResponse(ok=ok, message=msg) else: # feishu if not stored.get("feishu_enabled"): raise HTTPException(status_code=400, detail="飞书通知未启用") ok, msg = await send_feishu(stored.get("feishu_webhook_url", ""), ctx) return TestResponse(ok=ok, message=msg) @router.get("", response_model=list[dict[str, Any]]) def list_all(db: DBSession, _: AdminUser) -> list[dict[str, Any]]: rows = db.query(Setting).order_by(Setting.key).all() return [{"key": r.key, "value": r.value[:200] + ("..." if len(r.value) > 200 else "")} for r in rows]