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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""系统设置 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]
|
||||
Reference in New Issue
Block a user