diff --git a/admin/routes/notifications.py b/admin/routes/notifications.py
index ae9fe30..9774e27 100644
--- a/admin/routes/notifications.py
+++ b/admin/routes/notifications.py
@@ -2,6 +2,7 @@ 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
@@ -14,7 +15,7 @@ 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
@@ -36,6 +37,78 @@ class NotificationListResponse(BaseModel):
items: list[NotificationEventResponse]
+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]
+
+
+@router.get("/ops-alerts", response_model=OpsAlertListResponse, summary="运维告警列表")
+def list_ops_alerts(
+ limit: int = Query(50, ge=1, le=200),
+ _: AdminUser = Depends(get_current_user),
+ 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(get_current_user),
+ 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)}
+
+
+
+ | 时间 | 类型 | 标题 | 摘要 | details |
+ {rows_html}
+
+
+
+"""
+ return HTMLResponse(html)
+
+
@router.get("", response_model=NotificationListResponse, summary="通知审计列表")
def list_notifications(
limit: int = Query(50, ge=1, le=200),
@@ -103,9 +176,6 @@ def list_notifications(
}
-page_router = APIRouter(tags=["notifications-ui"])
-
-
@page_router.get("/admin/notifications", include_in_schema=False)
def notification_audit_admin_page(
limit: int = Query(50, ge=1, le=200),
diff --git a/admin/tests/test_ops_alerts_admin.py b/admin/tests/test_ops_alerts_admin.py
new file mode 100644
index 0000000..645d818
--- /dev/null
+++ b/admin/tests/test_ops_alerts_admin.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from data.notifications.ops_alerts import OpsAlertSink
+
+
+def test_admin_ops_alert_list_and_page(client, auth_headers, settings, tmp_path: Path):
+ sink = OpsAlertSink(log_path=settings.ops_alert_log_path)
+ sink.emit(
+ alert_type="delivery_watchdog_failed",
+ title="Gaokao delivery watchdog detected failed events",
+ body="station failed once",
+ details={"channel": "station", "failed": 1},
+ )
+
+ api = client.get("/api/admin/notifications/ops-alerts", headers=auth_headers)
+ assert api.status_code == 200, api.text
+ body = api.json()
+ assert body["total"] == 1
+ assert body["items"][0]["alert_type"] == "delivery_watchdog_failed"
+ assert body["items"][0]["details"]["channel"] == "station"
+
+ page = client.get("/admin/ops-alerts", headers=auth_headers)
+ assert page.status_code == 200, page.text
+ assert "运维告警审计" in page.text
+ assert "delivery_watchdog_failed" in page.text
+ assert "station failed once" in page.text
diff --git a/data/notifications/ops_alerts.py b/data/notifications/ops_alerts.py
new file mode 100644
index 0000000..b372e5c
--- /dev/null
+++ b/data/notifications/ops_alerts.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from data.notifications.smtp_sender import SMTPDeliverySender, SMTPSettings
+from data.orders.models import utc_now_iso
+
+
+@dataclass
+class OpsAlertResult:
+ log_written: bool
+ emails_sent: int = 0
+
+
+class OpsAlertSink:
+ def __init__(
+ self,
+ *,
+ log_path: str,
+ recipients: list[str] | None = None,
+ email_sender: Any | None = None,
+ ) -> None:
+ self._log_path = Path(log_path)
+ self._recipients = recipients or []
+ self._email_sender = email_sender
+
+ def emit(self, *, alert_type: str, title: str, body: str, details: dict[str, Any]) -> OpsAlertResult:
+ self._log_path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "created_at": utc_now_iso(),
+ "alert_type": alert_type,
+ "title": title,
+ "body": body,
+ "details": details,
+ }
+ with self._log_path.open("a", encoding="utf-8") as fh:
+ fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
+
+ emails_sent = 0
+ if self._email_sender is not None and self._recipients:
+ for recipient in self._recipients:
+ self._email_sender.send_report_ready(
+ recipient=recipient,
+ order_id=str(details.get("order_id") or "ops-alert"),
+ subject=title,
+ body=body,
+ )
+ emails_sent += 1
+ return OpsAlertResult(log_written=True, emails_sent=emails_sent)
+
+
+def build_alert_sink_from_settings(settings) -> OpsAlertSink:
+ sender = None
+ if settings.smtp_host and settings.smtp_sender:
+ sender = SMTPDeliverySender(
+ SMTPSettings(
+ host=settings.smtp_host,
+ port=settings.smtp_port,
+ sender=settings.smtp_sender,
+ username=settings.smtp_username,
+ password=settings.smtp_password,
+ use_tls=settings.smtp_use_tls,
+ use_ssl=settings.smtp_use_ssl,
+ )
+ )
+ return OpsAlertSink(
+ log_path=settings.ops_alert_log_path,
+ recipients=settings.alert_recipients,
+ email_sender=sender,
+ )
diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md
index 3906261..db694c4 100644
--- a/docs/CURRENT_STATE.md
+++ b/docs/CURRENT_STATE.md
@@ -112,7 +112,7 @@
### P0 / P1 真实缺口
- 用户端 Web 自助支付闭环缺失
-- 生产通知链已有底层 validated/delivered 状态机、portal 通知审计页、后台独立通知审计页与站内提示;仍缺告警推送与生产级监控
+- 生产通知链已有底层 validated/delivered 状态机、portal 通知审计页、后台独立通知审计页、运维告警审计页与站内提示;仍缺真正的告警推送集成与生产级监控
- 前台入口已支持基础资料填写、附件上传与 5 步向导(基础信息 / 偏好与目标 / 已有方案与附件 / 协议确认 / 提交确认);仍缺更强的多文件策略与更细粒度字段校验
- 业务数据备份 / 恢复 / 密钥托管已有本地基线与验证,但异机备份和生产接入仍不足
- 隐私政策 / 服务协议 / 监护人同意 / 数据保留与删除流程仍缺前台/客服自助工单与正式法务版本
diff --git a/scripts/gaokao-delivery-watchdog.py b/scripts/gaokao-delivery-watchdog.py
index b349fb1..aafc9b5 100644
--- a/scripts/gaokao-delivery-watchdog.py
+++ b/scripts/gaokao-delivery-watchdog.py
@@ -13,6 +13,7 @@ if str(ROOT) not in sys.path:
def main() -> int:
from admin.config import load_settings
from data.notifications.dispatcher import DeliveryDispatcher
+ from data.notifications.ops_alerts import build_alert_sink_from_settings
parser = argparse.ArgumentParser(description="Watch delivery dispatch health")
parser.add_argument("--channel", default="station")
@@ -29,7 +30,25 @@ def main() -> int:
dispatcher.close()
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
- return 2 if result.failed > 0 else 0
+ if result.failed > 0:
+ sink = build_alert_sink_from_settings(settings)
+ sink.emit(
+ alert_type="delivery_watchdog_failed",
+ title="Gaokao delivery watchdog detected failed events",
+ body=(
+ f"channel={args.channel} processed={result.processed} "
+ f"validated={getattr(result, 'validated', 0)} delivered={getattr(result, 'delivered', 0)} failed={result.failed}"
+ ),
+ details={
+ "channel": args.channel,
+ "processed": result.processed,
+ "validated": getattr(result, 'validated', 0),
+ "delivered": getattr(result, 'delivered', 0),
+ "failed": result.failed,
+ },
+ )
+ return 2
+ return 0
if __name__ == "__main__":
diff --git a/tests/test_ops_alerts.py b/tests/test_ops_alerts.py
new file mode 100644
index 0000000..eadf1b7
--- /dev/null
+++ b/tests/test_ops_alerts.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from data.notifications.ops_alerts import OpsAlertSink
+
+
+def test_ops_alert_sink_appends_jsonl(tmp_path: Path):
+ log_path = tmp_path / "alerts.jsonl"
+ sink = OpsAlertSink(log_path=str(log_path), recipients=[])
+ result = sink.emit(
+ alert_type="test_alert",
+ title="Test Alert",
+ body="body",
+ details={"order_id": "O-1", "failed": 2},
+ )
+
+ assert result.log_written is True
+ assert result.emails_sent == 0
+ content = log_path.read_text(encoding="utf-8").strip().splitlines()
+ assert len(content) == 1
+ payload = json.loads(content[0])
+ assert payload["alert_type"] == "test_alert"
+ assert payload["details"]["order_id"] == "O-1"
+
+
+def test_ops_alert_sink_can_use_fake_email_sender(tmp_path: Path):
+ log_path = tmp_path / "alerts.jsonl"
+
+ class FakeSender:
+ def __init__(self) -> None:
+ self.sent: list[dict[str, str]] = []
+
+ def send_report_ready(self, *, recipient: str, order_id: str, subject: str, body: str) -> dict[str, str]:
+ payload = {
+ "recipient": recipient,
+ "order_id": order_id,
+ "subject": subject,
+ "body": body,
+ }
+ self.sent.append(payload)
+ return payload
+
+ sender = FakeSender()
+ sink = OpsAlertSink(log_path=str(log_path), recipients=["ops@example.com"], email_sender=sender)
+ result = sink.emit(
+ alert_type="watchdog_failed",
+ title="watchdog failed",
+ body="something failed",
+ details={"order_id": "O-2"},
+ )
+
+ assert result.emails_sent == 1
+ assert len(sender.sent) == 1
+ assert sender.sent[0]["recipient"] == "ops@example.com"