from __future__ import annotations import json from html import escape from pathlib import Path from typing import Any, Optional from fastapi import APIRouter, Depends, Query from fastapi.responses import HTMLResponse from pydantic import BaseModel from admin.auth import require_role from admin.config import Settings, get_settings_dep from admin.db import AdminUser router = APIRouter(prefix="/api/admin/notifications", tags=["notifications"]) page_router = APIRouter(tags=["notifications-ui"]) class NotificationEventResponse(BaseModel): order_id: str event_type: str channel: str status: str attempt_count: int last_attempt_at: str | None = None failure_reason: str | None = None created_at: str payload: dict[str, Any] | str | None = None class NotificationListResponse(BaseModel): total: int limit: int offset: int filters: dict[str, Any] items: list[NotificationEventResponse] _NOTIFICATION_PAYLOAD_ALLOWLIST = { "kind", "delivery_channel", "delivery_stage", "reason", "status", } def _sanitize_notification_payload(raw: str | None) -> dict[str, Any] | str | None: if not raw: return None try: payload = json.loads(raw) except Exception: return None if not isinstance(payload, dict): return None sanitized: dict[str, Any] = {} for key in _NOTIFICATION_PAYLOAD_ALLOWLIST: if key in payload: sanitized[key] = payload[key] return sanitized class OpsAlertEventResponse(BaseModel): created_at: str alert_type: str title: str body: str details: dict[str, Any] class OpsAlertListResponse(BaseModel): total: int items: list[OpsAlertEventResponse] class DeletionRequestResponse(BaseModel): order_id: str requester_name: str requester_contact: str reason: str scope: str confirm_guardian: bool created_at: str @router.get( "/deletion-requests", response_model=dict[str, Any], summary="删除申请列表", ) def list_deletion_requests( limit: int = Query(50, ge=1, le=200), order_id: Optional[str] = Query(None), _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: path = Path(settings.deletion_request_log_path) items: list[dict[str, Any]] = [] if path.exists(): for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: item = json.loads(line) except Exception: continue if not isinstance(item, dict): continue if order_id and str(item.get("order_id") or "") != order_id: continue items.append(item) items = items[-limit:] return {"total": len(items), "items": items} @page_router.get("/admin/deletion-requests", include_in_schema=False) def deletion_request_admin_page( limit: int = Query(50, ge=1, le=200), order_id: Optional[str] = Query(None), _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: payload = list_deletion_requests( limit=limit, order_id=order_id, _=_, settings=settings, ) rows = [] for item in payload["items"]: rows.append( "" f"{escape(str(item.get('created_at') or '-'))}" f"{escape(str(item.get('order_id') or '-'))}" f"{escape(str(item.get('requester_name') or '-'))}" f"{escape(str(item.get('requester_contact') or '-'))}" f"{escape(str(item.get('scope') or '-'))}" f"{escape(str(item.get('reason') or '-'))}" "" ) rows_html = "".join(rows) or "暂无删除申请" html = f""" 删除申请审计

删除申请审计

日志路径:{escape(settings.deletion_request_log_path)}

总数:{payload['total']}

{rows_html}
时间订单申请人联系方式范围原因
""" return HTMLResponse(html) @router.get("/ops-alerts", response_model=OpsAlertListResponse, summary="运维告警列表") def list_ops_alerts( limit: int = Query(50, ge=1, le=200), _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: path = Path(settings.ops_alert_log_path) items: list[dict[str, Any]] = [] if path.exists(): for line in path.read_text(encoding="utf-8").splitlines()[-limit:]: if not line.strip(): continue try: item = json.loads(line) except Exception: continue if isinstance(item, dict): items.append(item) return {"total": len(items), "items": items} @page_router.get("/admin/ops-alerts", include_in_schema=False) def ops_alert_audit_page( _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: payload = list_ops_alerts(limit=200, _=_, settings=settings) rows = [] for item in payload["items"]: details = escape(json.dumps(item.get("details") or {}, ensure_ascii=False, indent=2)) rows.append( "" f"{escape(str(item.get('created_at') or '-'))}" f"{escape(str(item.get('alert_type') or '-'))}" f"{escape(str(item.get('title') or '-'))}" f"{escape(str(item.get('body') or '-'))}" f"
{details}
" "" ) rows_html = "".join(rows) or "暂无运维告警" html = f""" 运维告警审计

运维告警审计

集中查看通知链路中的异常告警、摘要与上下文,方便快速判断是否需要人工介入。

告警来源文件{escape(settings.ops_alert_log_path)}
SMTP 告警收件人数量{len(settings.alert_recipients)}
IM Webhook 数量{len(settings.alert_webhook_urls)}
{rows_html}
时间类型标题摘要详细上下文
""" return HTMLResponse(html) @router.get("", response_model=NotificationListResponse, summary="通知审计列表") def list_notifications( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), order_id: Optional[str] = Query(None), status: Optional[str] = Query(None), channel: Optional[str] = Query(None), _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> dict[str, Any]: from data.notifications.email_service import DeliveryNotificationService service = DeliveryNotificationService.for_db(settings.orders_db_path) try: conn = service._conn sql = ( "SELECT order_id, event_type, channel, payload_json, status, attempt_count, last_attempt_at, failure_reason, created_at " "FROM delivery_notifications WHERE 1=1" ) params: list[Any] = [] if order_id: sql += " AND order_id=?" params.append(order_id) if status: sql += " AND status=?" params.append(status) if channel: sql += " AND channel=?" params.append(channel) count_sql = f"SELECT COUNT(*) FROM ({sql})" total = int(conn.execute(count_sql, tuple(params)).fetchone()[0]) sql += " ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?" rows = conn.execute(sql, tuple(params + [limit, offset])).fetchall() finally: service.close() items = [] for row in rows: payload = _sanitize_notification_payload(row["payload_json"]) items.append( { "order_id": row["order_id"], "event_type": row["event_type"], "channel": row["channel"], "status": row["status"], "attempt_count": int(row["attempt_count"]), "last_attempt_at": row["last_attempt_at"], "failure_reason": row["failure_reason"], "created_at": row["created_at"], "payload": payload, } ) return { "total": total, "limit": limit, "offset": offset, "filters": {"order_id": order_id, "status": status, "channel": channel}, "items": items, } @page_router.get("/admin/notifications", include_in_schema=False) def notification_audit_admin_page( limit: int = Query(50, ge=1, le=200), order_id: Optional[str] = Query(None), status: Optional[str] = Query(None), channel: Optional[str] = Query(None), _: AdminUser = Depends(require_role("admin")), settings: Settings = Depends(get_settings_dep), ) -> HTMLResponse: payload = list_notifications( limit=limit, offset=0, order_id=order_id, status=status, channel=channel, _=_, settings=settings, ) rows = [] for item in payload["items"]: body = escape(json.dumps(item.get("payload"), ensure_ascii=False, indent=2)) rows.append( "" f"{escape(str(item['order_id']))}" f"{escape(str(item['channel']))}" f"{escape(str(item['event_type']))}" f"{escape(str(item['status']))}" f"{escape(str(item['attempt_count']))}" f"{escape(str(item.get('last_attempt_at') or '-'))}" f"{escape(str(item.get('failure_reason') or '-'))}" f"
{body}
" "" ) rows_html = "".join(rows) or "暂无通知事件" html = f""" 后台通知审计

后台通知审计

集中查看订单通知状态、重试情况与失败原因,便于客服或运营快速定位交付链路问题。

总数{payload['total']}
当前筛选order_id={escape(str(order_id or '-'))} / status={escape(str(status or '-'))} / channel={escape(str(channel or '-'))}
查看重点优先关注失败原因、尝试次数与最后尝试时间,确认是否需要人工补发。
{rows_html}
订单渠道事件状态尝试次数最后尝试失败原因通知内容摘要
""" return HTMLResponse(html)