290 lines
11 KiB
Python
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
|