Files
opsxsql/backend/app/services/notification_sender.py
T
logan 0576b253e4 fix: 审批流修复 + 通知KeyError修复
审批流修复:
- 提交阶段(step_type=submit)自动approved,不再创建pending记录
- 审批权限添加auditor角色(require_role admin/dba/auditor)
- 审批完成判断排除execute步骤(step_type!=execute才计入has_next)
- review_ticket中加载workflow_steps用于step_type判断
- Ticket字段名修正: creator_id -> applicant_id

通知修复:
- send()中自动补充sql_preview字段(sql_content前100字符)
- 飞书/钉钉模板format改为format_map+安全fallback(缺失字段不报KeyError)
- DB中auditor用户role从developer修正为auditor

测试: 1182 passed, 0 failed
审批流全链路验证通过: 提交->DBA审核->审计员审核->admin执行->success
2026-05-25 11:41:03 +08:00

320 lines
14 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)
# 自动补充 sql_preview(取 sql_content 前100字符)
if "sql_preview" not in data and "sql_content" in data:
sql = data["sql_content"]
data["sql_preview"] = sql[:100] + ("..." if len(sql) > 100 else "")
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}&timestamp={timestamp}&sign={sign}"
else:
webhook_url = f"{webhook_url}?timestamp={timestamp}&sign={sign}"
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
# 安全格式化
safe_data = {**data}
for key in ["applicant", "ticket_id", "title", "db_type", "instance_name",
"target_database", "sql_content", "sql_preview", "approver",
"comment", "result", "duration", "affected_rows", "error",
"operator", "database", "size", "drift_count", "drift_details"]:
safe_data.setdefault(key, "")
title = template["title"].format_map(safe_data)
body = template["body"].format_map(safe_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"])
# 安全格式化:缺失字段用空字符串代替,避免 KeyError
safe_data = {**data}
for key in ["applicant", "ticket_id", "title", "db_type", "instance_name",
"target_database", "sql_content", "sql_preview", "approver",
"comment", "result", "duration", "affected_rows", "error",
"operator", "database", "size", "drift_count", "drift_details"]:
safe_data.setdefault(key, "")
title = template["title"].format_map(safe_data)
body = template["body"].format_map(safe_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