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
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user