feat(api): /alerts 列表+summary+ack 路由(读 data/alerts.db,router 级鉴权,spec §20.15.4) [vps]

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-25 22:54:07 +08:00
parent cc3b82ae2c
commit 97de54c883
3 changed files with 129 additions and 0 deletions
+3
View File
@@ -9,6 +9,7 @@ from .routes_portfolio import router as portfolio_router
from .routes_strategy import router as strategy_router
from .routes_factor import router as factor_eval_router, set_eval_db_path as set_factor_eval_db_path
from .routes_pipeline import router as pipeline_router, set_pipeline_eval_db_path
from .routes_alerts import router as alerts_router, set_alerts_db_path
from .auth import set_jwt_config
from .ws import manager
from sanguo_orchestrator.runner import Orchestrator, resolve_eval_db
@@ -45,10 +46,12 @@ def create_app(db_path: str, file_dir=None, auth_config=None, max_workers: int =
app.include_router(strategy_router, prefix="/api/v1")
app.include_router(factor_eval_router, prefix="/api/v1")
app.include_router(pipeline_router, prefix="/api/v1")
app.include_router(alerts_router, prefix="/api/v1")
set_paper_db_path(db_path)
set_live_db_path(db_path)
set_factor_eval_db_path(resolve_eval_db(db_path))
set_pipeline_eval_db_path(resolve_eval_db(db_path))
set_alerts_db_path(None) # None=env SANGUO_ALERTS_DB > 各机缺省 data/alerts.db
@app.on_event("startup")
def _register_live_step():
+61
View File
@@ -0,0 +1,61 @@
"""运维告警 API(spec §20.15.4): 只读列表+summary+人工 ack; 写入方=监控脚本(单写者).
alert 库路径: env SANGUO_ALERTS_DB > 各机缺省 data/alerts.db(相对 cwd)——
与监控脚本 wrapper(Set-Location C:\\sanguo_vnpy_v2)解析到同一文件。
"""
from fastapi import APIRouter, Depends, HTTPException
from sanguo_api.routes import verify_token
from sanguo_data import alerts_store as store
router = APIRouter(dependencies=[Depends(verify_token)])
_db = {"path": None}
def set_alerts_db_path(p):
"""create_app 注入(同 routes_paper.set_db_path 模式); None=缺省解析."""
_db["path"] = p
def _conn():
return store.connect(_db["path"] or store.default_alerts_db_path())
@router.get("/alerts")
def list_alerts(source: str = None, severity: str = None, status: str = None,
host: str = None, limit: int = 500, offset: int = 0) -> dict:
limit = max(1, min(limit, 2000))
offset = max(0, offset)
conn = _conn()
try:
items, total = store.list_alerts(conn, source=source, severity=severity,
status=status, host=host,
limit=limit, offset=offset)
finally:
conn.close()
return {"items": items, "total": total}
@router.get("/alerts/summary")
def alerts_summary() -> dict:
conn = _conn()
try:
return store.summary(conn)
finally:
conn.close()
@router.post("/alerts/{alert_id}/ack")
def ack_alert(alert_id: str) -> dict:
conn = _conn()
try:
try:
status = store.ack_alert(conn, alert_id, acked_by="user")
except KeyError:
raise HTTPException(404, f"alert 不存在: {alert_id}")
finally:
conn.close()
if status is None:
raise HTTPException(400, "已 resolved 的告警无需 ack")
return {"alert_id": alert_id, "status": status}
+65
View File
@@ -0,0 +1,65 @@
"""routes_alerts 薄层测试(本地跑, CI 不含 tests/api): 列表筛选/summary/ack 三态."""
import pytest
from fastapi.testclient import TestClient
from sanguo_api.main import build_app
from sanguo_api.auth import create_token, set_jwt_config
from sanguo_data import alerts_store as store
def _cfg(tmp_path) -> str:
cfg = tmp_path / "bt.yaml"
cfg.write_text(
"backtest:\n max_workers: 1\n db_path: %s\n file_dir: %s\n"
"api:\n host: 0.0.0.0\n port: 8000\n"
"auth:\n username: admin\n password_hash: x\n jwt_secret: s\n token_expire_minutes: 60\n"
"pool:\n max_workers: 1\n" % (tmp_path / "r.db", tmp_path / "f")
)
return str(cfg)
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_ALERTS_DB", str(tmp_path / "alerts.db"))
with store.connect(str(tmp_path / "alerts.db")) as conn:
store.upsert_alert(conn, {
"alert_id": "data-t-1-20260925", "check_key": "data-t-1",
"source": "data", "host": "vps", "severity": "red",
"title": "t1", "detail": "", "evidence": {"x": 1}})
set_jwt_config(secret="s", expire_minutes=60)
app = build_app(_cfg(tmp_path))
c = TestClient(app)
c.headers.update({"Authorization": f"Bearer {create_token('admin')}"})
return c
def test_list_and_summary(client):
r = client.get("/api/v1/alerts")
assert r.status_code == 200 and r.json()["total"] == 1
assert r.json()["items"][0]["evidence"] == {"x": 1}
r = client.get("/api/v1/alerts/summary")
assert r.json() == {"open_red": 1,
"by_source": {"data": 1, "strategy": 0,
"factor": 0, "infra": 0}}
def test_list_filter_param(client):
assert client.get("/api/v1/alerts",
params={"severity": "yellow"}).json()["total"] == 0
def test_ack_flow(client):
r = client.post("/api/v1/alerts/data-t-1-20260925/ack")
assert r.status_code == 200 and r.json()["status"] == "acked"
# 已 ack(非 open) 再 ack → 400 语义(resolved/no-op 同通道)
r2 = client.post("/api/v1/alerts/data-t-1-20260925/ack")
assert r2.status_code == 400
r3 = client.post("/api/v1/alerts/nope/ack")
assert r3.status_code == 404
def test_unauthorized_rejected(tmp_path, monkeypatch):
monkeypatch.setenv("SANGUO_ALERTS_DB", str(tmp_path / "alerts.db"))
set_jwt_config(secret="s", expire_minutes=60)
c = TestClient(build_app(_cfg(tmp_path)))
assert c.get("/api/v1/alerts").status_code == 401