http_routes.py 9.69 KB
"""HTTP route registration for the BettaFish web API."""

from __future__ import annotations

import traceback
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Mapping, Protocol

from flask import Flask, jsonify, redirect, render_template, request
from loguru import logger

FrontendDevUrlResolver = Callable[[], str | None]
SearchRequestSubmitter = Callable[..., tuple[dict[str, Any], int]]
SocketEmit = Callable[[str, dict[str, Any]], None]
LogReader = Callable[[Path, str, int | None], list[str]]
LogWriter = Callable[[Path, str, str], None]
ForumStartHandler = Callable[..., bool]
ForumStopHandler = Callable[..., None]
ForumOutputGetter = Callable[..., dict[str, Any]]
ForumLogPayloadGetter = Callable[..., dict[str, Any]]
ForumLogHistoryGetter = Callable[..., dict[str, Any]]
ProcessStatusGetter = Callable[..., dict[str, dict[str, Any]]]
SystemStatusGetter = Callable[[], dict[str, Any]]
StreamlitAppStarter = Callable[..., tuple[bool, str]]
StreamlitStartupWaiter = Callable[[str, int], tuple[bool, str]]
StreamlitAppStopper = Callable[[str], tuple[bool, str]]


class SystemLifecycleContract(Protocol):
    """Protocol used by system routes to start and stop the platform."""

    def start_system(self) -> tuple[dict[str, Any], int]:
        ...

    def shutdown_system(self, *, cleanup_timeout: float = 6.0) -> tuple[dict[str, Any], int]:
        ...


@dataclass(frozen=True)
class HttpRouteDependencies:
    """Runtime services needed by extracted HTTP routes."""

    frontend_dev_server_url: FrontendDevUrlResolver
    log_dir: Path
    read_log: LogReader
    write_log: LogWriter
    socket_emit: SocketEmit
    get_process_status: ProcessStatusGetter
    get_system_status: SystemStatusGetter
    system_lifecycle: SystemLifecycleContract
    streamlit_scripts: Mapping[str, str]
    start_streamlit_app: StreamlitAppStarter
    wait_for_app_startup: StreamlitStartupWaiter
    stop_streamlit_app: StreamlitAppStopper
    start_forum_engine: ForumStartHandler
    stop_forum_engine: ForumStopHandler
    get_forum_output: ForumOutputGetter
    get_forum_log_payload: ForumLogPayloadGetter
    get_forum_log_history: ForumLogHistoryGetter
    submit_search_request: SearchRequestSubmitter


def register_http_routes(app: Flask, deps: HttpRouteDependencies) -> None:
    """Register runtime HTTP routes on the Flask application."""

    @app.route("/")
    def index():
        """涓婚〉"""
        frontend_dev_url = deps.frontend_dev_server_url()
        if frontend_dev_url:
            return redirect(frontend_dev_url)

        frontend_index = Path(app.static_folder) / "frontend" / "index.html"
        if frontend_index.exists():
            return app.send_static_file("frontend/index.html")
        return render_template("index.html")

    @app.route("/api/status")
    def get_status():
        """Return the status of all managed apps."""
        return jsonify(deps.get_process_status(include_output_lines=True))

    @app.route("/api/start/<app_name>")
    def start_app(app_name: str):
        """鍚姩鎸囧畾搴旂敤"""
        process_status = deps.get_process_status()
        if app_name not in process_status:
            return jsonify({"success": False, "message": "鏈煡搴旂敤"})

        if app_name == "forum":
            try:
                deps.start_forum_engine(emit_output=deps.socket_emit)
                return jsonify({"success": True, "message": "ForumEngine已启动"})
            except Exception as exc:  # pragma: no cover
                logger.exception("鎵嬪姩鍚姩ForumEngine澶辫触")
                return jsonify({"success": False, "message": f"ForumEngine鍚姩澶辫触: {exc}"})

        script_path = deps.streamlit_scripts.get(app_name)
        if not script_path:
            return jsonify({"success": False, "message": "璇ュ簲鐢ㄤ笉鏀寔鍚姩鎿嶄綔"})

        success, message = deps.start_streamlit_app(
            app_name,
            script_path,
            process_status[app_name]["port"],
            log_dir=deps.log_dir,
            emit_output=deps.socket_emit,
        )

        if success:
            startup_success, startup_message = deps.wait_for_app_startup(app_name, 15)
            if not startup_success:
                message += f" 浣嗗惎鍔ㄦ鏌ュけ璐? {startup_message}"

        return jsonify({"success": success, "message": message})

    @app.route("/api/stop/<app_name>")
    def stop_app(app_name: str):
        """鍋滄鎸囧畾搴旂敤"""
        if app_name not in deps.get_process_status():
            return jsonify({"success": False, "message": "鏈煡搴旂敤"})

        if app_name == "forum":
            try:
                deps.stop_forum_engine(emit_output=deps.socket_emit)
                return jsonify({"success": True, "message": "ForumEngine已停止"})
            except Exception as exc:  # pragma: no cover
                logger.exception("鎵嬪姩鍋滄ForumEngine澶辫触")
                return jsonify({"success": False, "message": f"ForumEngine鍋滄澶辫触: {exc}"})

        success, message = deps.stop_streamlit_app(app_name)
        return jsonify({"success": success, "message": message})

    @app.route("/api/output/<app_name>")
    def get_output(app_name: str):
        """鑾峰彇搴旂敤杈撳嚭"""
        if app_name not in deps.get_process_status():
            return jsonify({"success": False, "message": "鏈煡搴旂敤"})

        if app_name == "forum":
            try:
                return jsonify(deps.get_forum_output(log_dir=deps.log_dir))
            except Exception as exc:
                return jsonify({"success": False, "message": f"璇诲彇forum鏃ュ織澶辫触: {str(exc)}"})

        output_lines = deps.read_log(deps.log_dir, app_name)
        return jsonify({"success": True, "output": output_lines})

    @app.route("/api/test_log/<app_name>")
    def test_log(app_name: str):
        """娴嬭瘯鏃ュ織鍐欏叆鍔熻兘"""
        if app_name not in deps.get_process_status():
            return jsonify({"success": False, "message": "鏈煡搴旂敤"})

        test_msg = f"[{datetime.now().strftime('%H:%M:%S')}] 娴嬭瘯鏃ュ織娑堟伅 - {datetime.now()}"
        deps.write_log(deps.log_dir, app_name, test_msg)
        deps.socket_emit("console_output", {"app": app_name, "line": test_msg})
        return jsonify({"success": True, "message": f"娴嬭瘯娑堟伅宸插啓鍏?{app_name} 鏃ュ織"})

    @app.route("/api/forum/start")
    def start_forum_monitoring_api():
        """鎵嬪姩鍚姩ForumEngine璁哄潧"""
        try:
            deps.start_forum_engine(emit_output=deps.socket_emit)
            return jsonify({"success": True, "message": "ForumEngine论坛已启动"})
        except Exception as exc:
            return jsonify({"success": False, "message": f"鍚姩璁哄潧澶辫触: {str(exc)}"})

    @app.route("/api/forum/stop")
    def stop_forum_monitoring_api():
        """鎵嬪姩鍋滄ForumEngine璁哄潧"""
        try:
            deps.stop_forum_engine(emit_output=deps.socket_emit)
            return jsonify({"success": True, "message": "ForumEngine论坛已停止"})
        except Exception as exc:
            return jsonify({"success": False, "message": f"鍋滄璁哄潧澶辫触: {str(exc)}"})

    @app.route("/api/forum/log")
    def get_forum_log():
        """鑾峰彇ForumEngine鐨刦orum.log鍐呭"""
        try:
            return jsonify(deps.get_forum_log_payload(log_dir=deps.log_dir))
        except Exception as exc:
            return jsonify({"success": False, "message": f"璇诲彇forum.log澶辫触: {str(exc)}"})

    @app.route("/api/forum/log/history", methods=["POST"])
    def get_forum_log_history_api():
        """鑾峰彇Forum鍘嗗彶鏃ュ織锛堟敮鎸佷粠鎸囧畾浣嶇疆寮€濮嬶級"""
        try:
            data = request.get_json() or {}
            start_position = data.get("position", 0)
            max_lines = data.get("max_lines", 1000)
            return jsonify(
                deps.get_forum_log_history(
                    log_dir=deps.log_dir,
                    start_position=start_position,
                    max_lines=max_lines,
                )
            )
        except Exception as exc:
            return jsonify({"success": False, "message": f"璇诲彇forum鍘嗗彶澶辫触: {str(exc)}"})

    @app.route("/api/search", methods=["POST"])
    def search():
        """统一搜索接口(异步分发版)"""
        try:
            data = request.get_json(silent=True) or {}
            result, status_code = deps.submit_search_request(
                payload=data,
            )
            return jsonify(result), status_code
        except Exception as exc:
            logger.exception(f"/api/search 执行失败: {exc}")
            return (
                jsonify(
                    {
                        "success": False,
                        "message": f"/api/search 执行失败: {exc}",
                        "traceback": traceback.format_exc(),
                    }
                ),
                500,
            )

    @app.route("/api/system/status")
    def get_system_status():
        """Return the current system startup state."""
        return jsonify(deps.get_system_status())

    @app.route("/api/system/start", methods=["POST"])
    def start_system():
        """Start the full platform after receiving a request."""
        payload, status_code = deps.system_lifecycle.start_system()
        return jsonify(payload), status_code

    @app.route("/api/system/shutdown", methods=["POST"])
    def shutdown_system():
        """Gracefully stop all managed services and shut down the server."""
        payload, status_code = deps.system_lifecycle.shutdown_system(cleanup_timeout=6.0)
        return jsonify(payload), status_code