From 3ebbec3317fe2871c8ef2dc79ec4fd8f8740bf48 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:32:04 +0800 Subject: [PATCH] perf(startup): reduce readiness work (#6341) --- app/api/apiv1.py | 43 ++------ app/api/router_specs.py | 84 ++++++++++++++++ app/startup/routers_initializer.py | 11 ++- tests/test_router_aggregation.py | 152 +++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 39 deletions(-) create mode 100644 app/api/router_specs.py create mode 100644 tests/test_router_aggregation.py diff --git a/app/api/apiv1.py b/app/api/apiv1.py index 43d078cb6..089fcbe27 100644 --- a/app/api/apiv1.py +++ b/app/api/apiv1.py @@ -1,40 +1,11 @@ from fastapi import APIRouter -from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, music, \ - media, douban, search, plugin, tmdb, history, system, download, dashboard, \ - transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification +from app.api.router_specs import API_V1_ROUTER_SPECS api_router = APIRouter() -api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) -api_router.include_router(login.router, prefix="/login", tags=["login"]) -api_router.include_router(user.router, prefix="/user", tags=["user"]) -api_router.include_router(mfa.router, prefix="/mfa", tags=["mfa"]) -api_router.include_router(site.router, prefix="/site", tags=["site"]) -api_router.include_router(message.router, prefix="/message", tags=["message"]) -api_router.include_router(agent.router, prefix="/message/agent", tags=["agent"]) -api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) -api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"]) -api_router.include_router(music.router, prefix="/music", tags=["music"]) -api_router.include_router(media.router, prefix="/media", tags=["media"]) -api_router.include_router(search.router, prefix="/search", tags=["search"]) -api_router.include_router(douban.router, prefix="/douban", tags=["douban"]) -api_router.include_router(tmdb.router, prefix="/tmdb", tags=["tmdb"]) -api_router.include_router(history.router, prefix="/history", tags=["history"]) -api_router.include_router(system.router, prefix="/system", tags=["system"]) -api_router.include_router(notification.router, prefix="/notification", tags=["notification"]) -api_router.include_router(llm.router, prefix="/llm", tags=["llm"]) -api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"]) -api_router.include_router(download.router, prefix="/download", tags=["download"]) -api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) -api_router.include_router(storage.router, prefix="/storage", tags=["storage"]) -api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"]) -api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"]) -api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"]) -api_router.include_router(anilist.router, prefix="/anilist", tags=["anilist"]) -api_router.include_router(discover.router, prefix="/discover", tags=["discover"]) -api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"]) -api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"]) -api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"]) -api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"]) -api_router.include_router(openai.router, prefix="/openai/v1", tags=["openai"]) -api_router.include_router(anthropic.router, prefix="/anthropic/v1", tags=["anthropic"]) +for spec in API_V1_ROUTER_SPECS: + api_router.include_router( + spec.router, + prefix=spec.prefix, + tags=list(spec.tags), + ) diff --git a/app/api/router_specs.py b/app/api/router_specs.py new file mode 100644 index 000000000..4ca24df50 --- /dev/null +++ b/app/api/router_specs.py @@ -0,0 +1,84 @@ +from typing import NamedTuple + +from fastapi import APIRouter + +from app.api.endpoints import ( + agent, + anilist, + anthropic, + auth, + bangumi, + dashboard, + discover, + douban, + download, + history, + llm, + login, + mcp, + media, + mediaserver, + message, + mfa, + music, + notification, + openai, + plugin, + recommend, + search, + site, + storage, + subscribe, + system, + tmdb, + torrent, + transfer, + user, + webhook, + workflow, +) + + +class RouterSpec(NamedTuple): + """声明一个 v1 端点路由器及其公开路径元数据。""" + + router: APIRouter # 原始端点路由器 + prefix: str # 相对 v1 根路径的前缀 + tags: tuple[str, ...] # 追加到端点 OpenAPI 操作的标签 + + +API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = ( + RouterSpec(auth.router, "/auth", ("auth",)), + RouterSpec(login.router, "/login", ("login",)), + RouterSpec(user.router, "/user", ("user",)), + RouterSpec(mfa.router, "/mfa", ("mfa",)), + RouterSpec(site.router, "/site", ("site",)), + RouterSpec(message.router, "/message", ("message",)), + RouterSpec(agent.router, "/message/agent", ("agent",)), + RouterSpec(webhook.router, "/webhook", ("webhook",)), + RouterSpec(subscribe.router, "/subscribe", ("subscribe",)), + RouterSpec(music.router, "/music", ("music",)), + RouterSpec(media.router, "/media", ("media",)), + RouterSpec(search.router, "/search", ("search",)), + RouterSpec(douban.router, "/douban", ("douban",)), + RouterSpec(tmdb.router, "/tmdb", ("tmdb",)), + RouterSpec(history.router, "/history", ("history",)), + RouterSpec(system.router, "/system", ("system",)), + RouterSpec(notification.router, "/notification", ("notification",)), + RouterSpec(llm.router, "/llm", ("llm",)), + RouterSpec(plugin.router, "/plugin", ("plugin",)), + RouterSpec(download.router, "/download", ("download",)), + RouterSpec(dashboard.router, "/dashboard", ("dashboard",)), + RouterSpec(storage.router, "/storage", ("storage",)), + RouterSpec(transfer.router, "/transfer", ("transfer",)), + RouterSpec(mediaserver.router, "/mediaserver", ("mediaserver",)), + RouterSpec(bangumi.router, "/bangumi", ("bangumi",)), + RouterSpec(anilist.router, "/anilist", ("anilist",)), + RouterSpec(discover.router, "/discover", ("discover",)), + RouterSpec(recommend.router, "/recommend", ("recommend",)), + RouterSpec(workflow.router, "/workflow", ("workflow",)), + RouterSpec(torrent.router, "/torrent", ("torrent",)), + RouterSpec(mcp.router, "/mcp", ("mcp",)), + RouterSpec(openai.router, "/openai/v1", ("openai",)), + RouterSpec(anthropic.router, "/anthropic/v1", ("anthropic",)), +) diff --git a/app/startup/routers_initializer.py b/app/startup/routers_initializer.py index 2c56e5f81..6a31759f6 100644 --- a/app/startup/routers_initializer.py +++ b/app/startup/routers_initializer.py @@ -7,11 +7,16 @@ def init_routers(app: FastAPI): """ 初始化路由 """ - from app.api.apiv1 import api_router + from app.api.router_specs import API_V1_ROUTER_SPECS from app.api.servarr import arr_router from app.api.servcookie import cookie_router - # API路由 - app.include_router(api_router, prefix=settings.API_V1_STR) + # 直接聚合端点路由,避免先构建兼容路由器再克隆到最终应用。 + for spec in API_V1_ROUTER_SPECS: + app.include_router( + spec.router, + prefix=f"{settings.API_V1_STR}{spec.prefix}", + tags=list(spec.tags), + ) # Radarr、Sonarr路由 app.include_router(arr_router, prefix="/api/v3") # CookieCloud路由 diff --git a/tests/test_router_aggregation.py b/tests/test_router_aggregation.py new file mode 100644 index 000000000..f3cc56bea --- /dev/null +++ b/tests/test_router_aggregation.py @@ -0,0 +1,152 @@ +from typing import Any + +import httpx +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.routing import APIRoute + +from app.api.deps import get_current_active_user_async +from app.runtime.config import settings + + +def _v1_routes(app: FastAPI) -> list[APIRoute]: + """返回最终应用中的 v1 API 路由。""" + return [ + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path.startswith(f"{settings.API_V1_STR}/") + ] + + +def _route_contract(route: APIRoute) -> tuple[Any, ...]: + """提取直接聚合前后必须保持一致的公开路由合同。""" + return ( + type(route), + route.path, + tuple(sorted(route.methods or ())), + route.name, + route.endpoint, + tuple(route.tags), + route.status_code, + route.response_model, + route.response_class, + route.responses, + tuple( + ( + dependency.dependency, + dependency.use_cache, + tuple(dependency.scopes or ()), + ) + for dependency in route.dependencies + ), + route.operation_id, + route.unique_id, + route.include_in_schema, + route.deprecated, + ) + + +def test_init_routers_directly_includes_endpoint_router_specs(monkeypatch): + """启动聚合应直接 include 原始端点路由器并一次性附加完整 v1 前缀。""" + from app.api.router_specs import API_V1_ROUTER_SPECS + from app.startup.routers_initializer import init_routers + + app = FastAPI() + include_calls = [] + original_include_router = app.include_router + + def record_include_router(router, **kwargs): + """记录启动聚合参数后继续执行 FastAPI 的公开 include 接口。""" + include_calls.append((router, kwargs.get("prefix"), kwargs.get("tags"))) + return original_include_router(router, **kwargs) + + monkeypatch.setattr(app, "include_router", record_include_router) + + init_routers(app) + + v1_calls = include_calls[: len(API_V1_ROUTER_SPECS)] + assert [router for router, _, _ in v1_calls] == [ + spec.router for spec in API_V1_ROUTER_SPECS + ] + assert [prefix for _, prefix, _ in v1_calls] == [ + f"{settings.API_V1_STR}{spec.prefix}" for spec in API_V1_ROUTER_SPECS + ] + assert [tuple(tags or ()) for _, _, tags in v1_calls] == [ + spec.tags for spec in API_V1_ROUTER_SPECS + ] + assert [prefix for _, prefix, _ in include_calls[-2:]] == [ + "/api/v3", + "/cookiecloud", + ] + + +def test_direct_v1_routes_and_openapi_match_compatibility_router(): + """最终应用的 v1 路由合同与 OpenAPI 应和兼容聚合结果完全一致。""" + from app.api.apiv1 import api_router + from app.startup.routers_initializer import init_routers + + compatibility_app = FastAPI() + compatibility_app.include_router(api_router, prefix=settings.API_V1_STR) + direct_app = FastAPI() + init_routers(direct_app) + + compatibility_routes = [ + route + for route in compatibility_app.routes + if isinstance(route, APIRoute) + ] + direct_routes = _v1_routes(direct_app) + + assert [_route_contract(route) for route in direct_routes] == [ + _route_contract(route) for route in compatibility_routes + ] + assert all( + route.dependency_overrides_provider is direct_app for route in direct_routes + ) + assert all( + route.dependency_overrides_provider is compatibility_app + for route in compatibility_routes + ) + direct_v1_paths = { + path: item + for path, item in direct_app.openapi()["paths"].items() + if path.startswith(f"{settings.API_V1_STR}/") + } + assert direct_v1_paths == compatibility_app.openapi()["paths"] + + +@pytest.mark.anyio +async def test_direct_routes_honor_application_dependency_overrides(): + """直接聚合后的路由仍应由最终 FastAPI 应用解析依赖覆盖。""" + from app.startup.routers_initializer import init_routers + + app = FastAPI() + init_routers(app) + app.dependency_overrides[get_current_active_user_async] = lambda: object() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get(f"{settings.API_V1_STR}/system/ping") + + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "", "data": None} + + +def test_compatibility_api_router_keeps_public_contract(): + """历史导出应继续提供无 v1 根前缀的标准 APIRouter 与固定路由集合。""" + from app.api.apiv1 import api_router + from app.api.router_specs import API_V1_ROUTER_SPECS + + assert type(api_router) is APIRouter + assert len(api_router.routes) == sum( + len(spec.router.routes) for spec in API_V1_ROUTER_SPECS + ) + assert all( + isinstance(route, APIRoute) + and route.path.startswith("/") + and not route.path.startswith(f"{settings.API_V1_STR}/") + for route in api_router.routes + )