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"""
运维告警审计
运维告警审计
集中查看通知链路中的异常告警、摘要与上下文,方便快速判断是否需要人工介入。
时间 类型 标题 摘要 详细上下文
{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"""
后台通知审计
后台通知审计
集中查看订单通知状态、重试情况与失败原因,便于客服或运营快速定位交付链路问题。
订单 渠道 事件 状态 尝试次数 最后尝试 失败原因 通知内容摘要
{rows_html}
"""
return HTMLResponse(html)