"""Debug API — 系统日志与运行状态""" 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 from app.config import settings, is_trading_hours router = APIRouter(prefix="/api/debug", tags=["debug"]) @router.get("/errors") async def get_errors( limit: int = 50, source: str = None, level: str = None, q: str = None, days: int = 7, _admin: dict = Depends(get_current_admin), ): """获取错误日志(管理员)""" start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") async with get_db() as db: conditions = ["created_at >= :start"] params = {"start": start} if source: conditions.append("source = :source") params["source"] = source if level: conditions.append("level = :level") params["level"] = level if q: conditions.append("(message LIKE :q OR detail LIKE :q OR source LIKE :q)") params["q"] = f"%{q.strip()}%" where = " AND ".join(conditions) # 总数 count_result = await db.execute( text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params ) total = count_result.scalar() or 0 # 查询 params["limit"] = limit result = await db.execute( text( f"SELECT id, source, level, message, detail, created_at " f"FROM error_logs WHERE {where} " f"ORDER BY created_at DESC LIMIT :limit" ), params, ) rows = result.fetchall() errors = [] for row in rows: r = row._mapping errors.append({ "id": r["id"], "source": r["source"], "level": r["level"], "message": r["message"], "detail": r["detail"] or "", "created_at": str(r["created_at"]) if r["created_at"] else "", }) # 可选的 source/level 列表(用于前端过滤) sources_result = await db.execute( text("SELECT DISTINCT source FROM error_logs ORDER BY source") ) sources = [r[0] for r in sources_result.fetchall()] levels_result = await db.execute( text("SELECT DISTINCT level FROM error_logs ORDER BY level") ) levels = [r[0] for r in levels_result.fetchall()] source_counts_result = await db.execute( text(f"SELECT source, COUNT(*) FROM error_logs WHERE {where} GROUP BY source ORDER BY COUNT(*) DESC"), {key: value for key, value in params.items() if key != "limit"}, ) source_counts = {r[0]: r[1] for r in source_counts_result.fetchall()} level_counts_result = await db.execute( text(f"SELECT level, COUNT(*) FROM error_logs WHERE {where} GROUP BY level ORDER BY COUNT(*) DESC"), {key: value for key, value in params.items() if key != "limit"}, ) level_counts = {r[0]: r[1] for r in level_counts_result.fetchall()} return { "total": total, "errors": errors, "sources": sources, "levels": levels, "source_counts": source_counts, "level_counts": level_counts, } @router.delete("/errors") async def clear_errors( days: int = 30, _admin: dict = Depends(get_current_admin), ): """清除旧错误日志(管理员)""" cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") async with get_db() as db: result = await db.execute( text("DELETE FROM error_logs WHERE created_at < :cutoff"), {"cutoff": cutoff}, ) deleted = result.rowcount await db.commit() return {"status": "ok", "deleted": deleted} @router.get("/system") async def system_status(_admin: dict = Depends(get_current_admin)): """系统运行状态摘要(管理员)""" from app.engine.recommender import _scan_running, _scan_lock async with get_db() as db: # 各表数据量 tables_counts = {} for t in ["recommendations", "sector_heat", "market_temperature", "recommendation_tracking", "stock_diagnoses", "error_logs", "scan_process_logs", "users"]: result = await db.execute(text(f"SELECT COUNT(*) FROM {t}")) tables_counts[t] = result.scalar() or 0 # 最近 24h 错误数 since = (datetime.now() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") result = await db.execute( text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :since"), {"since": since}, ) recent_errors = result.scalar() or 0 # 最近错误 result = await db.execute( text("SELECT source, message, created_at FROM error_logs ORDER BY created_at DESC LIMIT 5") ) last_errors = [ {"source": r[0], "message": r[1], "created_at": str(r[2])} for r in result.fetchall() ] # 数据库文件大小 db_path = settings.database_url.replace("sqlite:///", "") db_size_mb = 0 if os.path.exists(db_path): db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2) return { "is_trading": is_trading_hours(), "scan_running": _scan_running, "scan_locked": _scan_lock.locked(), "recent_errors": recent_errors, "last_errors": last_errors, "tables_counts": tables_counts, "db_size_mb": db_size_mb, } def _decode_detail(raw: str | None) -> dict: if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {"value": parsed} except Exception: return {"raw": raw} def _row_observation(row) -> dict: r = row._mapping return { "id": r["id"], "scan_session": r["scan_session"], "scan_mode": r["scan_mode"] or "", "ts_code": r["ts_code"], "name": r["name"], "theme_name": r["theme_name"] or "", "stock_role": r["stock_role"] or "", "action_plan": r["action_plan"] or "观察", "final_score": r["final_score"] or 0, "catalyst_score": r["catalyst_score"] or 0, "theme_money_score": r["theme_money_score"] or 0, "stock_money_score": r["stock_money_score"] or 0, "emotion_role_score": r["emotion_role_score"] or 0, "timing_score": r["timing_score"] or 0, "entry_signal_type": r["entry_signal_type"] or "none", "elimination_reason": r["elimination_reason"] or "", "detail": _decode_detail(r["detail_json"]), "created_at": str(r["created_at"]) if r["created_at"] else "", } @router.get("/scan-logs") async def get_scan_logs( limit: int = 100, scan_session: str = None, days: int = 7, _admin: dict = Depends(get_current_admin), ): """获取筛选过程日志(管理员)""" start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") limit = max(1, min(limit, 300)) async with get_db() as db: selected_session = scan_session if not selected_session: latest = await db.execute( text( "SELECT scan_session FROM scan_process_logs " "WHERE created_at >= :start " "ORDER BY created_at DESC LIMIT 1" ), {"start": start}, ) selected_session = latest.scalar() if not selected_session: return {"scan_session": None, "logs": []} result = await db.execute( text( "SELECT id, scan_session, scan_mode, stage, stage_label, status, " "input_count, output_count, filtered_count, summary, detail_json, created_at " "FROM scan_process_logs " "WHERE created_at >= :start AND scan_session = :scan_session " "ORDER BY created_at ASC LIMIT :limit" ), {"start": start, "scan_session": selected_session, "limit": limit}, ) rows = result.fetchall() logs = [] for row in rows: r = row._mapping logs.append({ "id": r["id"], "scan_session": r["scan_session"], "scan_mode": r["scan_mode"] or "", "stage": r["stage"], "stage_label": r["stage_label"], "status": r["status"] or "ok", "input_count": r["input_count"] or 0, "output_count": r["output_count"] or 0, "filtered_count": r["filtered_count"] or 0, "summary": r["summary"] or "", "detail": _decode_detail(r["detail_json"]), "created_at": str(r["created_at"]) if r["created_at"] else "", }) return { "scan_session": selected_session, "logs": logs, } @router.get("/research-observations") async def get_research_observations( scan_session: str = None, limit: int = 80, days: int = 7, _admin: dict = Depends(get_current_admin), ): """获取候选股投研观察记录(管理员)""" start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") limit = max(1, min(limit, 200)) async with get_db() as db: selected_session = scan_session if not selected_session: latest = await db.execute( text( "SELECT scan_session FROM research_observations " "WHERE created_at >= :start ORDER BY created_at DESC LIMIT 1" ), {"start": start}, ) selected_session = latest.scalar() if not selected_session: return {"scan_session": None, "observations": [], "reason_counts": {}} result = await db.execute( text( "SELECT id, scan_session, scan_mode, ts_code, name, theme_name, stock_role, " "action_plan, final_score, catalyst_score, theme_money_score, stock_money_score, " "emotion_role_score, timing_score, entry_signal_type, elimination_reason, detail_json, created_at " "FROM research_observations " "WHERE created_at >= :start AND scan_session = :scan_session " "ORDER BY final_score DESC LIMIT :limit" ), {"start": start, "scan_session": selected_session, "limit": limit}, ) rows = result.fetchall() observations = [_row_observation(row) for row in rows] reason_counts = {} for item in observations: for part in (item["elimination_reason"] or "未知").split(";"): if not part: continue reason_counts[part] = reason_counts.get(part, 0) + 1 return { "scan_session": selected_session, "observations": observations, "reason_counts": reason_counts, } @router.get("/scan-sessions") async def get_scan_sessions( days: int = 7, limit: int = 30, _admin: dict = Depends(get_current_admin), ): """获取筛选会话摘要(管理员)""" start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") limit = max(1, min(limit, 100)) async with get_db() as db: result = await db.execute( text( "SELECT scan_session, scan_mode, stage, status, input_count, " "output_count, filtered_count, summary, created_at " "FROM scan_process_logs " "WHERE created_at >= :start " "ORDER BY created_at DESC LIMIT 1000" ), {"start": start}, ) rows = result.fetchall() sessions = {} order = [] for row in rows: r = row._mapping session_id = r["scan_session"] if session_id not in sessions: sessions[session_id] = { "scan_session": session_id, "scan_mode": r["scan_mode"] or "", "created_at": str(r["created_at"]) if r["created_at"] else "", "stage_count": 0, "status": "ok", "input_count": 0, "final_count": 0, "drop_count": 0, "last_summary": r["summary"] or "", } order.append(session_id) item = sessions[session_id] item["stage_count"] += 1 item["input_count"] = max(item["input_count"], int(r["input_count"] or 0)) item["drop_count"] += int(r["filtered_count"] or 0) if r["stage"] == "final_filter" and item["final_count"] == 0: item["final_count"] = int(r["output_count"] or 0) item["last_summary"] = r["summary"] or item["last_summary"] status = (r["status"] or "ok").lower() if status in {"failed", "error", "critical"}: item["status"] = "failed" elif status in {"warning", "empty"} and item["status"] == "ok": item["status"] = status return { "sessions": [sessions[sid] for sid in order[:limit]], } @router.get("/data-source-health") async def get_data_source_health( days: int = 7, _admin: dict = Depends(get_current_admin), ): """数据源健康摘要(管理员,只读)。""" start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") known_sources = ["eastmoney", "tencent", "tushare", "akshare", "sina", "news", "tushare_news"] health = { source: { "source": source, "status": "ok", "error_count": 0, "warning_count": 0, "last_error": "", "last_seen_at": "", } for source in known_sources } async with get_db() as db: result = await db.execute( text( "SELECT source, level, message, created_at FROM error_logs " "WHERE created_at >= :start " "ORDER BY created_at DESC LIMIT 500" ), {"start": start}, ) rows = result.fetchall() table_rows = {} for table_name, sql in { "market_temperature": "SELECT trade_date, created_at FROM market_temperature ORDER BY id DESC LIMIT 1", "sector_heat": "SELECT trade_date, created_at FROM sector_heat ORDER BY id DESC LIMIT 1", "recommendations": "SELECT created_at FROM recommendations ORDER BY id DESC LIMIT 1", "news_items": "SELECT created_at FROM news_items ORDER BY id DESC LIMIT 1", "catalysts": "SELECT created_at FROM catalysts ORDER BY id DESC LIMIT 1", }.items(): row = (await db.execute(text(sql))).fetchone() table_rows[table_name] = dict(row._mapping) if row else {} for row in rows: r = row._mapping source_text = str(r["source"] or "").lower() matched = next((source for source in known_sources if source in source_text), source_text or "unknown") if matched not in health: health[matched] = { "source": matched, "status": "ok", "error_count": 0, "warning_count": 0, "last_error": "", "last_seen_at": "", } item = health[matched] level_text = str(r["level"] or "error").lower() if level_text in {"warning", "warn"}: item["warning_count"] += 1 if item["status"] == "ok": item["status"] = "warning" else: item["error_count"] += 1 item["status"] = "error" if not item["last_error"]: item["last_error"] = r["message"] or "" item["last_seen_at"] = str(r["created_at"] or "") return { "days": days, "sources": sorted(health.values(), key=lambda item: (item["status"] != "error", item["status"] != "warning", item["source"])), "freshness": { key: {k: str(v or "") for k, v in value.items()} for key, value in table_rows.items() }, "generated_at": datetime.now().isoformat(), } @router.get("/tasks") async def get_tasks(_admin: dict = Depends(get_current_admin)): """后台任务中心摘要(管理员,只读)。""" from app.engine.scheduler import scheduler from app.engine.recommender import _scan_running, _scan_lock jobs = [] for job in scheduler.get_jobs(): jobs.append({ "id": job.id, "name": job.name, "next_run_time": str(job.next_run_time) if job.next_run_time else "", "trigger": str(job.trigger), }) async with get_db() as db: recent_scan = await db.execute( text( "SELECT scan_session, scan_mode, stage, status, output_count, summary, created_at " "FROM scan_process_logs ORDER BY created_at DESC LIMIT 12" ) ) recent_errors = await db.execute( text( "SELECT source, level, message, created_at FROM error_logs " "ORDER BY created_at DESC LIMIT 8" ) ) return { "scheduler_running": scheduler.running, "scan_running": _scan_running, "scan_locked": _scan_lock.locked(), "job_count": len(jobs), "jobs": sorted(jobs, key=lambda item: item["next_run_time"] or "9999"), "recent_scan_logs": [ { "scan_session": r._mapping["scan_session"], "scan_mode": r._mapping["scan_mode"] or "", "stage": r._mapping["stage"], "status": r._mapping["status"] or "ok", "output_count": r._mapping["output_count"] or 0, "summary": r._mapping["summary"] or "", "created_at": str(r._mapping["created_at"] or ""), } for r in recent_scan.fetchall() ], "recent_errors": [ { "source": r._mapping["source"], "level": r._mapping["level"], "message": r._mapping["message"], "created_at": str(r._mapping["created_at"] or ""), } for r in recent_errors.fetchall() ], "generated_at": datetime.now().isoformat(), }