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,