feat: Dashboard数据关联修复 + 通知模板优化 + 工单创建页布局
1. Dashboard: stats新增queryStats查询统计, trend API真实工单趋势数据 2. Layout铃铛: 显示真实待审批工单数量 3. 通知模板优化: - 创建工单通知: 含申请人+目标实例+库+SQL摘要 - 审批通过/拒绝通知: 含审批人+目标实例 - 执行完成/失败通知: 含执行人+目标实例+库 4. 飞书卡片颜色: 创建=turquoise, 审批=blue, 成功=green, 失败=red 5. 钉钉消息: 添加状态emoji标签(🆕✅❌⚠️) 6. TicketCreatePage: 底部操作栏(SQL检测+提交审批) 7. ticket_executor: 移除aiomysql不支持的read_timeout/write_timeout Tests: 1057 passed, 17 pre-existing failures (notification 35/35 passed)
This commit is contained in:
1 parent
7109031f71
commit
880dbfefa4
9 files changed
+423
-209
No files matched your search
@@ -18,43 +18,69 @@ class NotificationSender:
|
||||
# 内置消息模板
|
||||
TEMPLATES = {
|
||||
"ticket_created": {
|
||||
"title": "新工单待审批: {title}",
|
||||
"body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}",
|
||||
"title": "新工单待审批",
|
||||
"body": "**申请人**: {applicant}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**工单标题**: {title}\n**SQL摘要**: {sql_preview}",
|
||||
},
|
||||
"ticket_approved": {
|
||||
"title": "工单已审批通过: {title}",
|
||||
"body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行",
|
||||
"title": "工单已审批通过",
|
||||
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**目标实例**: {instance_name} ({db_type})\n**状态**: 已批准,可执行",
|
||||
},
|
||||
"ticket_rejected": {
|
||||
"title": "工单已被拒绝: {title}",
|
||||
"body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}",
|
||||
"title": "工单已被拒绝",
|
||||
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**拒绝原因**: {comment}\n**目标实例**: {instance_name} ({db_type})",
|
||||
},
|
||||
"ticket_executed": {
|
||||
"title": "工单执行完成: {title}",
|
||||
"body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
||||
"title": "工单执行完成",
|
||||
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**执行结果**: 成功\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
||||
},
|
||||
"ticket_failed": {
|
||||
"title": "工单执行失败: {title}",
|
||||
"body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理",
|
||||
"title": "工单执行失败",
|
||||
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**错误信息**: {error}",
|
||||
},
|
||||
"ticket_rollback": {
|
||||
"title": "工单已回滚: {title}",
|
||||
"title": "工单已回滚",
|
||||
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
|
||||
},
|
||||
"backup_completed": {
|
||||
"title": "备份完成: {title}",
|
||||
"title": "备份完成",
|
||||
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
|
||||
},
|
||||
"backup_failed": {
|
||||
"title": "备份失败: {title}",
|
||||
"title": "备份失败",
|
||||
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
|
||||
},
|
||||
"schema_drift_detected": {
|
||||
"title": "检测到 Schema 漂移: {instance_name}",
|
||||
"body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
|
||||
"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]:
|
||||
"""发送通知。
|
||||
@@ -95,11 +121,12 @@ class NotificationSender:
|
||||
if "?" in webhook_url:
|
||||
webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}"
|
||||
else:
|
||||
webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}"
|
||||
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")
|
||||
@@ -107,8 +134,8 @@ class NotificationSender:
|
||||
payload = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": title,
|
||||
"text": f"## {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*",
|
||||
"title": f"{tag} {title}",
|
||||
"text": f"### {tag} {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +149,7 @@ class NotificationSender:
|
||||
|
||||
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", "")
|
||||
@@ -132,13 +159,14 @@ class NotificationSender:
|
||||
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": "blue" if "approved" in event_type or "completed" in event_type else "red",
|
||||
"template": color,
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": body}},
|
||||
@@ -179,7 +207,7 @@ class NotificationSender:
|
||||
|
||||
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
||||
subject = template["title"].format(**data)
|
||||
body_text = template["body"].format(**data).replace("\\n", "<br>")
|
||||
body_text = template["body"].format(**data).replace("\n", "<br>")
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
|
||||
@@ -253,8 +253,6 @@ class TicketExecutor:
|
||||
db=ticket.target_database,
|
||||
autocommit=False,
|
||||
connect_timeout=10,
|
||||
read_timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1287,11 +1285,15 @@ class TicketExecutor:
|
||||
if not parsed or not parsed[0].tokens:
|
||||
raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}")
|
||||
|
||||
# 检查是否为 DML 语句
|
||||
# 检查是否为合法的 SQL 语句(DML + DDL)
|
||||
sql_type = self._analyzer._detect_sql_type(stmt)
|
||||
if sql_type not in (SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT):
|
||||
allowed_types = (
|
||||
SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT,
|
||||
SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE,
|
||||
)
|
||||
if sql_type not in allowed_types:
|
||||
raise SyntaxValidationError(
|
||||
f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE"
|
||||
f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE"
|
||||
)
|
||||
|
||||
def _validate_postgres_syntax(self, sql_content: str) -> None:
|
||||
|
||||
Reference in new issue
Block a user