Files
system-backu/backend/app/api/settings.py
T
publ 07b2016f33 feat: ETCD 备份 + 邮件/飞书通知
ETCD 备份(新增类型):
- 后端 core/backup/etcd.py:调用 etcdctl snapshot save(v3.5.17 二进制)
  生成快照文件,与 META.txt 一起 tar.gz 流式打包上传。
- 配置支持 endpoints(逗号分隔多节点)、basic auth、TLS(cacert/cert/key)。
- Dockerfile 下载 etcdctl 二进制(绕过 apt 签名问题)。

通知模块:
- 核心 app/core/notify.py:
  - 邮件:smtplib + MIMEMultipart(线程池里跑避免阻塞)
  - 飞书:httpx 调自定义机器人 webhook,发 interactive card
- settings API:
  - GET/PUT /api/settings/notifications(密码脱敏 *** 编辑占位)
  - POST /api/settings/notifications/test 测试通道
- Job 增加 notify_on 字段(none/failure/all),Alembic 0002 迁移。
- executor 在 run 完成后根据 job.notify_on 与 run.status 异步触发通知,
  重新加载最新 run 信息生成上下文。

前端:
- JobForm 加 ETCD 表单字段与 notify_on 选择器;type 选项加 etcd。
- JobList 显示通知策略徽标。
- Settings 页加通知配置卡片:邮件(折叠面板 + 测试按钮)+ 飞书 + 保存。
- 新增 api/settings.ts。
- 类型扩展:EtcdSourceConfig、NotificationsConfig、SmtpConfig。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 11:24:53 +08:00

155 lines
4.7 KiB
Python

"""系统设置 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]