astock-agent/backend/app/api/debug.py
2026-06-10 08:36:25 +08:00

290 lines
11 KiB
Python

"""Debug and operations visibility APIs."""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import text
from app.core.deps import get_current_admin
from app.db.database import get_db
router = APIRouter(prefix="/api/debug", tags=["debug"])
@router.get("/system")
async def get_system_status(_admin: dict = Depends(get_current_admin)):
async with get_db() as db:
errors = await db.execute(text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :start"), {"start": _days_ago(1)})
scans = await db.execute(text("SELECT COUNT(*) FROM scan_process_logs WHERE created_at >= :start"), {"start": _days_ago(1)})
latest_scan = await db.execute(text("SELECT * FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
latest_error = await db.execute(text("SELECT * FROM error_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
error_count = errors.scalar() or 0
scan_count = scans.scalar() or 0
return {
"status": "warning" if error_count else "ok",
"generated_at": datetime.now().isoformat(),
"db_size_mb": _db_size_mb(),
"today_error_count": error_count,
"today_scan_log_count": scan_count,
"latest_scan": _row(latest_scan.fetchone()),
"latest_error": _row(latest_error.fetchone()),
}
@router.get("/errors")
async def get_error_logs(
limit: int = 50,
source: str | None = None,
level: str | None = None,
days: int = 7,
q: str | None = None,
_admin: dict = Depends(get_current_admin),
):
clauses = ["created_at >= :start"]
params: dict = {"start": _days_ago(days), "limit": min(max(limit, 1), 200)}
if source:
clauses.append("source = :source")
params["source"] = source
if level:
clauses.append("level = :level")
params["level"] = level
if q:
clauses.append("(message LIKE :q OR detail LIKE :q)")
params["q"] = f"%{q}%"
where = " AND ".join(clauses)
async with get_db() as db:
result = await db.execute(
text(
f"SELECT * FROM error_logs WHERE {where} "
"ORDER BY created_at DESC, id DESC LIMIT :limit"
),
params,
)
count = await db.execute(text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params)
rows = [_row(row) for row in result.fetchall()]
sources = sorted({row.get("source") for row in rows if row.get("source")})
levels = sorted({row.get("level") for row in rows if row.get("level")})
return {
"total": count.scalar() or 0,
"errors": rows,
"logs": rows,
"sources": sources,
"levels": levels,
"source_counts": _count_by(rows, "source"),
"level_counts": _count_by(rows, "level"),
}
@router.delete("/errors")
async def clear_error_logs(days: int = 30, _admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(
text("DELETE FROM error_logs WHERE created_at < :start"),
{"start": _days_ago(days)},
)
await db.commit()
return {"status": "ok", "deleted": result.rowcount or 0}
@router.get("/scan-sessions")
async def get_scan_sessions(days: int = 7, limit: int = 30, _admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(
text(
"SELECT scan_session, MAX(scan_mode) AS scan_mode, MAX(created_at) AS created_at, "
"COUNT(*) AS stage_count, MAX(status) AS status, MAX(input_count) AS input_count, "
"MAX(output_count) AS final_count, SUM(filtered_count) AS drop_count, MAX(summary) AS last_summary "
"FROM scan_process_logs WHERE created_at >= :start "
"GROUP BY scan_session ORDER BY MAX(created_at) DESC LIMIT :limit"
),
{"start": _days_ago(days), "limit": min(max(limit, 1), 100)},
)
return {"sessions": [_row(row) for row in result.fetchall()]}
@router.get("/scan-logs")
async def get_scan_logs(scan_session: str | None = None, days: int = 7, limit: int = 120, _admin: dict = Depends(get_current_admin)):
async with get_db() as db:
if not scan_session:
latest = await db.execute(text("SELECT scan_session FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 1"))
row = latest.fetchone()
scan_session = row._mapping["scan_session"] if row else None
if not scan_session:
return {"scan_session": None, "logs": []}
result = await db.execute(
text(
"SELECT * FROM scan_process_logs WHERE scan_session = :session AND created_at >= :start "
"ORDER BY created_at ASC, id ASC LIMIT :limit"
),
{"session": scan_session, "start": _days_ago(days), "limit": min(max(limit, 1), 300)},
)
return {"scan_session": scan_session, "logs": [_scan_row(row) for row in result.fetchall()]}
@router.get("/tasks")
async def get_task_center(_admin: dict = Depends(get_current_admin)):
from app.engine import scheduler as scheduler_module
from app.engine.recommender import _scan_running
scheduler = getattr(scheduler_module, "scheduler", None)
jobs = []
if scheduler:
for job in scheduler.get_jobs():
jobs.append({
"id": job.id,
"name": job.name,
"next_run_time": str(job.next_run_time or ""),
"trigger": str(job.trigger),
})
async with get_db() as db:
scan_logs = await db.execute(
text("SELECT * FROM scan_process_logs ORDER BY created_at DESC, id DESC LIMIT 12")
)
errors = await db.execute(
text("SELECT source, level, message, created_at FROM error_logs ORDER BY created_at DESC, id DESC LIMIT 8")
)
return {
"scheduler_running": bool(scheduler and scheduler.running),
"scan_running": bool(_scan_running),
"scan_locked": bool(_scan_running),
"job_count": len(jobs),
"jobs": jobs,
"recent_scan_logs": [_row(row) for row in scan_logs.fetchall()],
"recent_errors": [_row(row) for row in errors.fetchall()],
"generated_at": datetime.now().isoformat(),
}
@router.get("/data-source-health")
async def get_data_source_health(days: int = 7, _admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(
text(
"SELECT source, "
"SUM(CASE WHEN level = 'error' THEN 1 ELSE 0 END) AS error_count, "
"SUM(CASE WHEN level = 'warning' THEN 1 ELSE 0 END) AS warning_count, "
"MAX(message) AS last_error, MAX(created_at) AS last_seen_at "
"FROM error_logs WHERE created_at >= :start GROUP BY source ORDER BY error_count DESC, warning_count DESC"
),
{"start": _days_ago(days)},
)
freshness_rows = {
"market_temperature": await _latest_row(db, "market_temperature", "市场温度", "created_at", "trade_date"),
"sector_heat": await _latest_row(db, "sector_heat", "板块主线", "created_at", "trade_date"),
"recommendations": await _latest_row(db, "recommendations", "机会清单", "created_at", "scan_session"),
"research_reports": await _latest_row(db, "research_reports", "研究报告", "created_at", "trade_date"),
"news_items": await _latest_row(db, "news_items", "消息催化", "created_at", "published_at"),
"scan_process_logs": await _latest_row(db, "scan_process_logs", "扫描记录", "created_at", "scan_session"),
}
sources = []
for row in result.fetchall():
item = _row(row)
sources.append({
**item,
"status": "error" if item.get("error_count", 0) else "warning" if item.get("warning_count", 0) else "ok",
})
freshness = {key: _freshness_status(value) for key, value in freshness_rows.items()}
return {"days": days, "sources": sources, "freshness": freshness, "generated_at": datetime.now().isoformat()}
def _days_ago(days: int) -> str:
return (datetime.now() - timedelta(days=max(1, days))).strftime("%Y-%m-%d %H:%M:%S")
def _db_size_mb() -> float:
path = os.environ.get("ASTOCK_DATABASE_URL", "").replace("sqlite:///", "") or "./astock.db"
if not os.path.exists(path):
path = "./data/astock.db"
try:
return round(os.path.getsize(path) / 1024 / 1024, 2)
except Exception:
return 0
def _row(row) -> dict:
if not row:
return {}
return {key: _json_safe(value) for key, value in dict(row._mapping).items()}
def _scan_row(row) -> dict:
data = _row(row)
raw = data.pop("detail_json", "{}")
try:
data["detail"] = json.loads(raw or "{}")
except Exception:
data["detail"] = {}
return data
async def _latest_row(db, table: str, label: str, time_column: str, ref_column: str) -> dict:
result = await db.execute(
text(
f"SELECT MAX({time_column}) AS last_success_at, MAX({ref_column}) AS last_reference, COUNT(*) AS total "
f"FROM {table}"
)
)
row = _row(result.fetchone())
row["label"] = label
row["table"] = table
return row
def _freshness_status(item: dict) -> dict:
last_success = item.get("last_success_at") or ""
status = "missing"
age_minutes = None
if last_success:
parsed = _parse_datetime(last_success)
if parsed:
age_minutes = round((datetime.now() - parsed).total_seconds() / 60, 1)
status = "ok" if age_minutes <= 360 else "stale"
return {
**item,
"status": status,
"age_minutes": age_minutes,
"message": _freshness_message(item.get("label", ""), status, age_minutes),
}
def _freshness_message(label: str, status: str, age_minutes: float | None) -> str:
if status == "missing":
return f"{label}暂无成功记录"
if age_minutes is None:
return f"{label}时间无法识别"
if age_minutes <= 60:
return f"{label}刚更新"
if age_minutes <= 360:
return f"{label}{round(age_minutes / 60, 1)}小时前更新"
return f"{label}超过6小时未更新"
def _parse_datetime(value: str):
if isinstance(value, datetime):
return value
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(str(value).split(".")[0], fmt)
except Exception:
continue
return None
def _json_safe(value):
if isinstance(value, datetime):
return value.isoformat()
return value
def _count_by(rows: list[dict], key: str) -> dict:
counts: dict[str, int] = {}
for row in rows:
value = row.get(key)
if value:
counts[str(value)] = counts.get(str(value), 0) + 1
return counts