"""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