"""JSON API blueprint: /api/* endpoints.""" import logging import sqlite3 import threading import time from flask import Blueprint, current_app, jsonify, request import config from extensions import get_db from repositories import issue_repo, project_repo, release_repo from services import analysis as analysis_service logger = logging.getLogger(__name__) bp = Blueprint("api", __name__, url_prefix="/api") # Simple in-memory rate limiter: {ip: [timestamp, ...]} # # 单进程假设 (OPT-8): 这个 dict 假设 Flask dev server 单进程运行. # 上 gunicorn 多 worker 时每个 worker 有独立 dict, 限流退化为 # N × RATE_LIMIT_MAX — 详见 docs/analysis/gunicorn-evaluation.md (NO-GO 决策). # 若未来真要上 gunicorn, 换成 Redis 或 flask-limiter 的共享 backend. _rate_limit_store = {} # 后台清理线程间隔 (秒). 独立 daemon thread 定期清理全表, # 防止孤立 IP (只来一次再不来的) 永久占内存. _RATE_LIMIT_CLEANUP_INTERVAL_S = 60 def _rate_limit_cleanup_once(now=None): """Drop IPs whose newest timestamp is older than the rate-limit window. Returns the number of entries removed. Idempotent, thread-safe-ish (dict.pop is atomic under CPython GIL). """ now = now if now is not None else time.time() cutoff = now - config.RATE_LIMIT_WINDOW removed = 0 for ip, timestamps in list(_rate_limit_store.items()): if not timestamps or timestamps[-1] < cutoff: _rate_limit_store.pop(ip, None) removed += 1 return removed def _rate_limit_cleanup_loop(): """Daemon thread body: full cleanup every _RATE_LIMIT_CLEANUP_INTERVAL_S.""" while True: time.sleep(_RATE_LIMIT_CLEANUP_INTERVAL_S) try: removed = _rate_limit_cleanup_once() if removed: logger.debug("rate-limit cleanup removed %d stale IP entries", removed) except Exception: # pragma: no cover - defensive, never crash the daemon logger.exception("rate-limit cleanup iteration failed") _cleanup_thread = threading.Thread( target=_rate_limit_cleanup_loop, name="rate-limit-cleanup", daemon=True, # daemon thread: dies with the process, no shutdown hook needed ) _cleanup_thread.start() def _check_rate_limit(ip): """Return True if request should be blocked (429). Hot path: only prunes the *current* IP's timestamps. Stale-IP full-table cleanup is delegated to the daemon thread (see _rate_limit_cleanup_loop) so this stays O(window) per call. """ now = time.time() if ip not in _rate_limit_store: _rate_limit_store[ip] = [] _rate_limit_store[ip] = [ t for t in _rate_limit_store[ip] if now - t < config.RATE_LIMIT_WINDOW ] if len(_rate_limit_store[ip]) >= config.RATE_LIMIT_MAX: return True _rate_limit_store[ip].append(now) return False @bp.route("/projects") def api_projects(): try: return jsonify(project_repo.list_projects(get_db())) except sqlite3.Error as exc: logger.exception("api_projects failed") return jsonify({"error": str(exc)}), 500 @bp.route("/projects/") def api_project_detail(project_id): try: proj = project_repo.get_project(get_db(), project_id) if proj is None: return jsonify({"error": "project not found"}), 404 return jsonify(proj) except sqlite3.Error as exc: logger.exception("api_project_detail failed") return jsonify({"error": str(exc)}), 500 @bp.route("/projects//releases") def api_project_releases(project_id): try: return jsonify(release_repo.list_releases_for_project(get_db(), project_id)) except sqlite3.Error as exc: logger.exception("api_project_releases failed") return jsonify({"error": str(exc)}), 500 @bp.route("/projects//issues") def api_project_issues(project_id): try: return jsonify(issue_repo.list_issues_for_project(get_db(), project_id)) except sqlite3.Error as exc: logger.exception("api_project_issues failed") return jsonify({"error": str(exc)}), 500 @bp.route("/search") def api_search(): keyword = request.args.get("q", "").strip() if not keyword: return jsonify({"projects": [], "releases": [], "issues": []}) try: db = get_db() return jsonify({ "projects": project_repo.search_projects(db, keyword), "releases": release_repo.search_releases(db, keyword), "issues": issue_repo.search_issues(db, keyword), }) except sqlite3.Error as exc: logger.exception("api_search failed") return jsonify({"error": str(exc)}), 500 @bp.route("/stats") def api_stats(): try: return jsonify(project_repo.get_stats(get_db())) except sqlite3.Error as exc: logger.exception("api_stats failed") return jsonify({"error": str(exc)}), 500 @bp.route("/presets") def api_presets(): return jsonify(analysis_service.get_presets()) @bp.route("/context/") def api_context(scope): """Return database context for a given scope (no LLM call).""" try: result, err = analysis_service.build_context(scope, get_db()) if err is not None: return jsonify(err[0]), err[1] return jsonify(result) except sqlite3.Error as exc: logger.exception("api_context failed") return jsonify({"error": str(exc)}), 500 @bp.route("/analyze/stream") def api_analyze_stream(): scope = request.args.get("scope", "home") question = request.args.get("question", "").strip() preset = request.args.get("preset", "").strip() client_ip = request.remote_addr or "127.0.0.1" if _check_rate_limit(client_ip): return ( jsonify({"error": "rate limit exceeded, max 3 requests per minute"}), 429, ) if not question and not preset: return jsonify({"error": "question or preset required"}), 400 question = analysis_service.resolve_question(question, preset) # 在 view 体内 (application context 仍存活) 就 eagerly 构建上下文 + 拼 prompt. # generator 在 view return 后才被消费, 闭包内不得再依赖 Flask g / request. try: data, ctx_err = analysis_service.build_context(scope, get_db()) if ctx_err is not None: return jsonify(ctx_err[0]), ctx_err[1] except Exception as e: current_app.logger.exception( "failed to build analysis context for scope=%s", scope ) return jsonify({"error": f"failed to build context: {str(e)}"}), 500 return analysis_service.stream_analysis_events(data, question), { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }