新增功能:
- ticket_sql_items模型: 逐条SQL执行记录(状态/耗时/影响行数)
- risk_evaluator.py: 4级风险评估(low/medium/high/critical), 支持MySQL/PG/Redis/Mongo
- Ticket模型: 新增risk_level + workflow_id字段
- 审核引擎签名改造: audit(sql, db_type, instance_id=None) 支持EXPLAIN预检
- 审核超时保护: 10s超时跳过审核, 防止工单创建阻塞
- 工单创建自动风险计算 + 审批流匹配(instance.workflow_id优先)
- SQL items查询API: GET /{ticket_id}/sql-items 含进度统计
- 执行器集成: 执行完成后自动更新SQL items逐条状态
- 通知脱敏: password/secret/token自动掩码, SQL预览截断100字符
- 高危操作强制二次确认: high/critical级别需confirmed=true
- 维护窗口控制: system_settings配置执行时间窗口(支持跨午夜)
- 45个新测试: 风险评估24+通知脱敏5+维护窗口2+SQL分类11+配置2
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""通知发送器 — 支持钉钉、飞书、邮件的实际发送。
|
|
|
|
使用 httpx 进行异步 HTTP 请求,aiosmtplib 发送邮件。
|
|
支持消息模板渲染,根据不同事件类型发送不同格式的消息。
|
|
"""
|
|
import time
|
|
import hmac
|
|
import hashlib
|
|
import base64
|
|
import json
|
|
from typing import Dict, Any, Optional
|
|
from urllib.parse import quote
|
|
|
|
|
|
class NotificationSender:
|
|
"""通知发送器,支持钉钉、飞书、邮件。"""
|
|
|
|
# 敏感字段关键词(用于通知脱敏)
|
|
_SENSITIVE_PATTERNS = [
|
|
(r"password\s*=\s*'[^']*'", "password='***'"),
|
|
(r"password\s*=\s*\"[^\"]*\"", 'password="***"'),
|
|
(r"secret\s*=\s*'[^']*'", "secret='***'"),
|
|
(r"token\s*=\s*'[^']*'", "token='***'"),
|
|
(r"DROP\s+TABLE\s+\w+", "DROP TABLE ***"),
|
|
(r"TRUNCATE\s+TABLE?\s*\w+", "TRUNCATE ***"),
|
|
(r"FLUSHALL", "FLUSHALL"),
|
|
(r"FLUSHDB", "FLUSHDB"),
|
|
]
|
|
|
|
@staticmethod
|
|
def _mask_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""对通知数据中的敏感字段进行脱敏。"""
|
|
import re
|
|
masked = dict(data)
|
|
# sql_preview 截断到前100字符 + 脱敏
|
|
if "sql_preview" in masked and masked["sql_preview"]:
|
|
preview = str(masked["sql_preview"])[:100]
|
|
for pattern, replacement in NotificationSender._SENSITIVE_PATTERNS:
|
|
preview = re.sub(pattern, replacement, preview, flags=re.IGNORECASE)
|
|
masked["sql_preview"] = preview + ("..." if len(str(data.get("sql_preview", ""))) > 100 else "")
|
|
# error 字段脱敏
|
|
if "error" in masked and masked["error"]:
|
|
err = str(masked["error"])[:200]
|
|
for pattern, replacement in NotificationSender._SENSITIVE_PATTERNS:
|
|
err = re.sub(pattern, replacement, err, flags=re.IGNORECASE)
|
|
masked["error"] = err
|
|
return masked
|
|
|
|
# 内置消息模板
|
|
TEMPLATES = {
|
|
"ticket_created": {
|
|
"title": "新工单待审批",
|
|
"body": "**申请人**: {applicant}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**工单标题**: {title}\n**SQL摘要**: {sql_preview}",
|
|
},
|
|
"ticket_approved": {
|
|
"title": "工单已审批通过",
|
|
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**目标实例**: {instance_name} ({db_type})\n**状态**: 已批准,可执行",
|
|
},
|
|
"ticket_rejected": {
|
|
"title": "工单已被拒绝",
|
|
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**拒绝原因**: {comment}\n**目标实例**: {instance_name} ({db_type})",
|
|
},
|
|
"ticket_executed": {
|
|
"title": "工单执行完成",
|
|
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**执行结果**: 成功\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
|
},
|
|
"ticket_failed": {
|
|
"title": "工单执行失败",
|
|
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**错误信息**: {error}",
|
|
},
|
|
"ticket_rollback": {
|
|
"title": "工单已回滚",
|
|
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
|
|
},
|
|
"backup_completed": {
|
|
"title": "备份完成",
|
|
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
|
|
},
|
|
"backup_failed": {
|
|
"title": "备份失败",
|
|
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
|
|
},
|
|
"schema_drift_detected": {
|
|
"title": "检测到 Schema 漂移",
|
|
"body": "**实例**: {instance_name}\n**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
|
|
},
|
|
}
|
|
|
|
# 飞书卡片颜色映射
|
|
FEISHU_COLORS = {
|
|
"ticket_created": "turquoise",
|
|
"ticket_approved": "blue",
|
|
"ticket_rejected": "red",
|
|
"ticket_executed": "green",
|
|
"ticket_failed": "red",
|
|
"ticket_rollback": "orange",
|
|
"backup_completed": "green",
|
|
"backup_failed": "red",
|
|
"schema_drift_detected": "orange",
|
|
}
|
|
|
|
# 钉钉消息颜色标记
|
|
DINGTALK_TAGS = {
|
|
"ticket_created": "🆕",
|
|
"ticket_approved": "✅",
|
|
"ticket_rejected": "❌",
|
|
"ticket_executed": "✅",
|
|
"ticket_failed": "❌",
|
|
"ticket_rollback": "⚠️",
|
|
"backup_completed": "✅",
|
|
"backup_failed": "❌",
|
|
"schema_drift_detected": "⚠️",
|
|
}
|
|
|
|
async def send(self, channel_type: str, config: Dict[str, Any],
|
|
event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""发送通知。
|
|
|
|
Args:
|
|
channel_type: 渠道类型 (dingtalk / feishu / email)
|
|
config: 渠道配置
|
|
event_type: 事件类型
|
|
data: 模板数据
|
|
"""
|
|
# 通知脱敏:隐藏 SQL 中的敏感内容
|
|
data = self._mask_sensitive_data(data)
|
|
|
|
try:
|
|
if channel_type == "dingtalk":
|
|
return await self._send_dingtalk(config, event_type, data)
|
|
elif channel_type in ("webhook_feishu", "feishu"):
|
|
return await self._send_feishu(config, event_type, data)
|
|
elif channel_type == "email":
|
|
return await self._send_email(config, event_type, data)
|
|
else:
|
|
return {"success": False, "message": f"不支持的通知类型: {channel_type}"}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"发送失败: {str(e)}"}
|
|
|
|
async def _send_dingtalk(self, config: Dict[str, Any], event_type: str,
|
|
data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""发送钉钉消息。"""
|
|
import httpx
|
|
|
|
webhook_url = config.get("webhook_url", "")
|
|
secret = config.get("secret", "")
|
|
|
|
if not webhook_url:
|
|
return {"success": False, "message": "缺少 webhook_url 配置"}
|
|
|
|
# 生成签名
|
|
if secret:
|
|
timestamp = str(int(time.time() * 1000))
|
|
sign = self._generate_dingtalk_sign(timestamp, secret)
|
|
if "?" in webhook_url:
|
|
webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}"
|
|
else:
|
|
webhook_url = f"{webhook_url}?timestamp={timestamp}&sign={sign}"
|
|
|
|
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
|
title = template["title"].format(**data)
|
|
body = template["body"].format(**data)
|
|
tag = self.DINGTALK_TAGS.get(event_type, "📌")
|
|
|
|
# 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行
|
|
body = body.replace("\n", "\n\n")
|
|
|
|
payload = {
|
|
"msgtype": "markdown",
|
|
"markdown": {
|
|
"title": f"{tag} {title}",
|
|
"text": f"### {tag} {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*",
|
|
}
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
response = await client.post(webhook_url, json=payload)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if result.get("errcode") == 0:
|
|
return {"success": True, "message": "钉钉消息发送成功"}
|
|
return {"success": False, "message": f"钉钉返回错误: {result}"}
|
|
|
|
async def _send_feishu(self, config: Dict[str, Any], event_type: str,
|
|
data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""发送飞书消息 — 使用 Interactive Card。"""
|
|
import httpx
|
|
|
|
webhook_url = config.get("webhook_url", "")
|
|
if not webhook_url:
|
|
return {"success": False, "message": "缺少 webhook_url 配置"}
|
|
|
|
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
|
title = template["title"].format(**data)
|
|
body = template["body"].format(**data)
|
|
color = self.FEISHU_COLORS.get(event_type, "blue")
|
|
|
|
payload = {
|
|
"msg_type": "interactive",
|
|
"card": {
|
|
"header": {
|
|
"title": {"tag": "plain_text", "content": title},
|
|
"template": color,
|
|
},
|
|
"elements": [
|
|
{"tag": "div", "text": {"tag": "lark_md", "content": body}},
|
|
{"tag": "note", "elements": [{"tag": "plain_text", "content": "来自 SQL 审核平台"}]},
|
|
]
|
|
}
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
response = await client.post(webhook_url, json=payload)
|
|
response.raise_for_status()
|
|
return {"success": True, "message": "飞书消息发送成功"}
|
|
|
|
async def _send_email(self, config: Dict[str, Any], event_type: str,
|
|
data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""发送邮件通知。"""
|
|
try:
|
|
import aiosmtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
except ImportError:
|
|
return {"success": False, "message": "缺少 aiosmtplib 依赖,请安装: pip install aiosmtplib"}
|
|
|
|
smtp_host = config.get("smtp_host", "")
|
|
smtp_port = config.get("smtp_port", 587)
|
|
smtp_user = config.get("smtp_user", "")
|
|
smtp_password = config.get("smtp_password", "")
|
|
from_addr = config.get("from_addr", smtp_user)
|
|
to_addr = data.get("recipient_email", "")
|
|
use_tls = config.get("use_tls", True)
|
|
|
|
if not all([smtp_host, smtp_user, smtp_password, to_addr]):
|
|
missing = [k for k, v in {
|
|
"smtp_host": smtp_host, "smtp_user": smtp_user,
|
|
"smtp_password": smtp_password, "recipient_email": to_addr,
|
|
}.items() if not v]
|
|
return {"success": False, "message": f"缺少邮件配置: {', '.join(missing)}"}
|
|
|
|
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
|
subject = template["title"].format(**data)
|
|
body_text = template["body"].format(**data).replace("\n", "<br>")
|
|
|
|
html_body = f"""
|
|
<html>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
|
<h2 style="color: #2c5aa0;">{subject}</h2>
|
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
|
|
{body_text}
|
|
</div>
|
|
<hr style="margin-top: 20px; border: none; border-top: 1px solid #ddd;">
|
|
<p style="color: #999; font-size: 12px;">本邮件由 SQL 审核平台自动发送</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = from_addr
|
|
msg["To"] = to_addr
|
|
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
|
|
|
await aiosmtplib.send(
|
|
msg,
|
|
hostname=smtp_host,
|
|
port=smtp_port,
|
|
username=smtp_user,
|
|
password=smtp_password,
|
|
start_tls=use_tls,
|
|
)
|
|
|
|
return {"success": True, "message": f"邮件已发送至 {to_addr}"}
|
|
|
|
def _generate_dingtalk_sign(self, timestamp: str, secret: str) -> str:
|
|
"""生成钉钉 webhook 签名。"""
|
|
string_to_sign = f"{timestamp}\n{secret}"
|
|
hmac_code = hmac.new(
|
|
secret.encode("utf-8"),
|
|
string_to_sign.encode("utf-8"),
|
|
digestmod=hashlib.sha256,
|
|
).digest()
|
|
return quote(base64.b64encode(hmac_code).decode("utf-8"))
|
|
|
|
async def notify_ticket_event(self, ticket_id: int, event_type: str,
|
|
channels: list, ticket_data: Dict[str, Any]) -> list:
|
|
"""发送工单事件通知到所有启用的渠道。"""
|
|
results = []
|
|
for channel in channels:
|
|
try:
|
|
config = json.loads(channel.config) if isinstance(channel.config, str) else channel.config
|
|
result = await self.send(
|
|
channel.channel_type, config, event_type, ticket_data
|
|
)
|
|
results.append({"channel": channel.name, **result})
|
|
except Exception as e:
|
|
results.append({"channel": getattr(channel, "name", "unknown"),
|
|
"success": False, "message": str(e)})
|
|
return results
|