07b2016f33
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>
155 lines
4.7 KiB
Python
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]
|