Files
blog-app/routes/api.py
2026-08-17 17:11:27 +08:00

205 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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/<int:project_id>")
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/<int:project_id>/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/<int:project_id>/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/<path:scope>")
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",
}