diff --git a/app/adapters/web/docs.py b/app/adapters/web/docs.py new file mode 100644 index 000000000..3f90861b7 --- /dev/null +++ b/app/adapters/web/docs.py @@ -0,0 +1,90 @@ +"""Swagger UI、ReDoc 与 OpenAPI 文档路由,按运行时开关逐请求决定是否开放。 + +FastAPI 自带的文档路由只能在创建应用时开启或关闭;这里始终注册路由、每次请求读取开关, +修改设置后无需重启。文档首次生成会常驻数十 MB 内存,关闭后的首个文档请求会丢弃已缓存的文档。 +""" + +from collections.abc import Callable + +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.openapi.docs import ( + get_redoc_html, + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.routing import APIRoute + +DOCS_PATH = "/docs" +DOCS_OAUTH2_REDIRECT_PATH = "/docs/oauth2-redirect" +REDOC_PATH = "/redoc" + + +def install_api_docs_routes( + app: FastAPI, + *, + openapi_path: str, + enabled: Callable[[], bool], +) -> None: + """注册文档路由;创建应用时需关闭 FastAPI 自带的同名路由。 + + :param app: 需要挂载文档的 FastAPI 应用 + :param openapi_path: OpenAPI JSON 的路径,不含反向代理的 root_path + :param enabled: 每次请求调用,返回当前是否开放文档 + """ + + def ensure_enabled() -> None: + """未开放时返回 404,并释放开放期间缓存的文档。""" + if enabled(): + return + app.openapi_schema = None + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + def root_path(request: Request) -> str: + """与 FastAPI 自带文档一致,文档页引用的地址带上反向代理前缀。""" + return str(request.scope.get("root_path", "")).rstrip("/") + + def openapi(_: Request) -> JSONResponse: + """返回 OpenAPI 文档;首次生成约需数秒 CPU,同步端点由 FastAPI 放进线程池,不阻塞事件循环。""" + ensure_enabled() + return JSONResponse(app.openapi()) + + async def swagger_ui(request: Request) -> HTMLResponse: + """返回 Swagger UI 页面。""" + ensure_enabled() + prefix = root_path(request) + return get_swagger_ui_html( + openapi_url=f"{prefix}{openapi_path}", + title=f"{app.title} - Swagger UI", + oauth2_redirect_url=f"{prefix}{DOCS_OAUTH2_REDIRECT_PATH}", + init_oauth=app.swagger_ui_init_oauth, + swagger_ui_parameters=app.swagger_ui_parameters, + ) + + async def swagger_ui_redirect(_: Request) -> HTMLResponse: + """返回 Swagger UI 的 OAuth2 回调页面。""" + ensure_enabled() + return get_swagger_ui_oauth2_redirect_html() + + async def redoc(request: Request) -> HTMLResponse: + """返回 ReDoc 页面。""" + ensure_enabled() + return get_redoc_html( + openapi_url=f"{root_path(request)}{openapi_path}", + title=f"{app.title} - ReDoc", + ) + + for path, endpoint, response_class in ( + (openapi_path, openapi, JSONResponse), + (DOCS_PATH, swagger_ui, HTMLResponse), + (DOCS_OAUTH2_REDIRECT_PATH, swagger_ui_redirect, HTMLResponse), + (REDOC_PATH, redoc, HTMLResponse), + ): + app.router.add_api_route( + path, + endpoint, + methods=["GET"], + response_class=response_class, + include_in_schema=False, + route_class_override=APIRoute, + ) diff --git a/app/application/settings/contract.py b/app/application/settings/contract.py index 3ba7b3cce..631889211 100644 --- a/app/application/settings/contract.py +++ b/app/application/settings/contract.py @@ -449,6 +449,8 @@ _RESTART_REQUIRED_GROUPS = { "security", "system_update", } +# 所在分组需要重启,但由调用方逐次读取、修改后立即生效的例外项。 +_IMMEDIATE_SETTINGS_IN_RESTART_GROUPS = frozenset({"API_DOCS_ENABLE"}) def _load_source_catalog() -> dict[str, Any]: @@ -543,6 +545,7 @@ def build_setting_specs() -> tuple[dict[str, SettingSpec], dict[str, SettingSpec apply_mode=( "restart_required" if group in _RESTART_REQUIRED_GROUPS + and key not in _IMMEDIATE_SETTINGS_IN_RESTART_GROUPS else "immediate" ), unit=_runtime_unit(key), diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index c20cdccfe..d237b22f3 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -17,55 +17,55 @@ "description": "AI智能体开关", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 811 + "source_line": 813 }, "AI_AGENT_GLOBAL": { "description": "合局AI智能体", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 813 + "source_line": 815 }, "AI_AGENT_HIDE_ENTRY": { "description": "是否隐藏前端全局智能体入口", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 815 + "source_line": 817 }, "AI_AGENT_JOB_INTERVAL": { "description": "AI智能体定时任务检查间隔(小时),0为不启用,默认24小时", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 864 + "source_line": 866 }, "AI_AGENT_RETRY_TRANSFER": { "description": "AI智能体自动重试整理失败记录开关", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 868 + "source_line": 870 }, "AI_AGENT_VERBOSE": { "description": "AI智能体啰嗦模式,开启后会回复工具调用过程", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 866 + "source_line": 868 }, "AI_RECOMMEND_ENABLED": { "description": "是否启用AI推荐", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 855 + "source_line": 857 }, "AI_RECOMMEND_MAX_ITEMS": { "description": "AI推荐条目数量限制", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 860 + "source_line": 862 }, "AI_RECOMMEND_USER_PREFERENCE": { "description": "AI推荐用户偏好", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 857 + "source_line": 859 }, "ALIPAN_APP_ID": { "description": "Alipan AppId", @@ -77,7 +77,7 @@ "description": "对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新)", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 804 + "source_line": 806 }, "ALLOWED_HOSTS": { "description": "允许的域名", @@ -97,6 +97,12 @@ "source_file": "app/runtime/config.py", "source_line": 378 }, + "API_DOCS_ENABLE": { + "description": "是否开放 API 文档;开放 /docs、/redoc 与 /api/v1/openapi.json,文档首次生成后常驻约 20–30MB 内存,修改后立即生效", + "group": "security", + "source_file": "app/runtime/config.py", + "source_line": 794 + }, "API_TOKEN": { "description": "API密钥,需要更换", "group": "authentication", @@ -137,67 +143,67 @@ "description": "音频输入 API 密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 875 + "source_line": 877 }, "AUDIO_INPUT_BASE_URL": { "description": "音频输入基础URL", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 877 + "source_line": 879 }, "AUDIO_INPUT_LANGUAGE": { "description": "音频输入识别语言", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 881 + "source_line": 883 }, "AUDIO_INPUT_MODEL": { "description": "音频输入模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 879 + "source_line": 881 }, "AUDIO_INPUT_PROVIDER": { "description": "音频输入提供商:openai/openai_chat_audio/mimo/minimax", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 873 + "source_line": 875 }, "AUDIO_OUTPUT_API_KEY": { "description": "音频输出 API 密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 885 + "source_line": 887 }, "AUDIO_OUTPUT_BASE_URL": { "description": "音频输出基础URL", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 887 + "source_line": 889 }, "AUDIO_OUTPUT_INCLUDE_TEXT": { "description": "回复语音时是否同时附带文字说明", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 893 + "source_line": 895 }, "AUDIO_OUTPUT_MODEL": { "description": "音频输出模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 889 + "source_line": 891 }, "AUDIO_OUTPUT_PROVIDER": { "description": "音频输出提供商:openai/openai_chat_audio/mimo/minimax", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 883 + "source_line": 885 }, "AUDIO_OUTPUT_VOICE": { "description": "音频输出音色/发音人", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 891 + "source_line": 893 }, "AUTH_SITE": { "description": "用户认证站点", @@ -284,7 +290,7 @@ "source_line": 331 }, "CACHE_REDIS_MAX_CONNECTIONS": { - "description": "Redis 连接池最大连接数", + "description": "Redis 单个连接池最大连接数;异步连接池按事件循环分别应用该上限", "group": "cache", "source_file": "app/runtime/config.py", "source_line": 333 @@ -797,121 +803,121 @@ "description": "LLM API密钥", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 833 + "source_line": 835 }, "LLM_API_PROTOCOL": { "description": "OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 823 + "source_line": 825 }, "LLM_BASE_URL": { "description": "LLM基础URL(用于自定义API端点)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 835 + "source_line": 837 }, "LLM_BASE_URL_PRESET": { "description": "LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 839 + "source_line": 841 }, "LLM_MAX_CONTEXT_TOKENS": { "description": "LLM最大上下文Token数量(K),用于目录缺失回退和未匹配兼容端点的用户上限", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 841 + "source_line": 843 }, "LLM_MAX_ITERATIONS": { "description": "LLM最大迭代次数", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 847 + "source_line": 849 }, "LLM_MAX_TOOLS": { "description": "LLM工具选择中间件最大工具数量,0为不启用工具选择中间件", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 862 + "source_line": 864 }, "LLM_MEMORY_RETENTION_DAYS": { "description": "内存记忆保留天数", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 853 + "source_line": 855 }, "LLM_MODEL": { "description": "LLM模型名称", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 819 + "source_line": 821 }, "LLM_PROVIDER": { "description": "LLM提供商(支持内置 provider,以及从 models.dev 动态补充的平台)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 817 + "source_line": 819 }, "LLM_SUPPORT_AUDIO_INPUT": { "description": "是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 829 + "source_line": 831 }, "LLM_SUPPORT_AUDIO_OUTPUT": { "description": "是否启用音频输出,开启后 Agent 可在支持渠道发送语音回复", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 831 + "source_line": 833 }, "LLM_SUPPORT_IMAGE_INPUT": { "description": "LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 827 + "source_line": 829 }, "LLM_TEMPERATURE": { "description": "LLM温度参数", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 845 + "source_line": 847 }, "LLM_THINKING_LEVEL": { "description": "思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 821 + "source_line": 823 }, "LLM_TOOL_TIMEOUT": { "description": "LLM工具调用超时时间(秒)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 849 + "source_line": 851 }, "LLM_USER_AGENT": { "description": "LLM OpenAI兼容接口请求User-Agent", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 843 + "source_line": 845 }, "LLM_USE_PROXY": { "description": "LLM调用是否使用系统代理", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 837 + "source_line": 839 }, "LLM_VERBOSE": { "description": "是否启用详细日志", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 851 + "source_line": 853 }, "LLM_WEB_SEARCH_MODE": { "description": "联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 825 + "source_line": 827 }, "LOCAL_EXISTS_SEARCH": { "description": "检查本地媒体库是否存在资源开关", @@ -1109,7 +1115,7 @@ "description": "对OpenList进行快照对比时,是否检查文件夹的修改时间", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 802 + "source_line": 804 }, "PACKAGE_CACHE_DAYS": { "description": "pip/uv 包下载缓存保留天数", @@ -1139,7 +1145,7 @@ "description": "Playwright浏览器类型,供智能体浏览器工具和插件直接使用 Playwright 时读取", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 807 + "source_line": 809 }, "PLUGIN_AUTO_RELOAD": { "description": "是否开启插件热加载", @@ -1187,7 +1193,7 @@ "description": "对rclone进行快照对比时,是否检查文件夹的修改时间", "group": "storage", "source_file": "app/runtime/config.py", - "source_line": 800 + "source_line": 802 }, "RECOGNIZE_PLUGIN_FIRST": { "description": "优先使用辅助识别", @@ -1409,7 +1415,7 @@ "description": "是否按媒体聚合整理失败通知,关闭时保持逐条发送", "group": "ai_agent", "source_file": "app/runtime/config.py", - "source_line": 870 + "source_line": 872 }, "TRANSFER_MAX_FAILED_RETRIES": { "description": "自动整理(目录监控、下载器轮询)遇到失败整理记录时,同一源路径允许自动重试的最大次数。 一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)不该让文件永久漏整理,因此必须重试; 但永远识别不出的文件重试再多也不会成功,只会重复推送失败通知,批量导入时更会刷屏, 因此必须有界。取值区间 1-10,越界或非法值会被钳制到边界并记录 warn: 既不支持关闭重试,也不支持无限重试。整理成功或删除整理记录时计数清零。 与本项无关的是同路径新版本:已成功整理的文件源大小变化时一律放行, 由整理链的 overwrite_mode 决断是否覆盖", @@ -1493,7 +1499,7 @@ "description": "工作流数据共享", "group": "workflow", "source_file": "app/runtime/config.py", - "source_line": 796 + "source_line": 798 }, "WRITE_TIMEOUT": { "description": "日志写入等待超时时间,单位秒", diff --git a/app/factory.py b/app/factory.py index e2124b81a..87961393d 100644 --- a/app/factory.py +++ b/app/factory.py @@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException from app.adapters.observability.otel import build_observation_port from app.adapters.web.correlation import CorrelationIdMiddleware +from app.adapters.web.docs import install_api_docs_routes from app.adapters.web.health import install_health_routes from app.adapters.web.metrics import HttpMetricsMiddleware from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry @@ -333,10 +334,14 @@ def create_app() -> FastAPI: """ configure_correlation_id_provider(get_correlation_id) configure_observation(build_observation_port()) + openapi_path = f"{get_runtime_setting('API_V1_STR')}/openapi.json" + # 自带文档路由只能在创建时开关;改由 install_api_docs_routes 按 API_DOCS_ENABLE 逐请求判断 _app = FastAPI( title=get_runtime_setting('PROJECT_NAME'), version=get_app_version(), - openapi_url=f"{get_runtime_setting('API_V1_STR')}/openapi.json", + openapi_url=None, + docs_url=None, + redoc_url=None, lifespan=lifespan ) @@ -354,6 +359,11 @@ def create_app() -> FastAPI: _app.router.route_class = ResponseAPIRoute # 编排器探针使用原生 APIRoute 和最小响应,不进入业务响应包络或版本前缀。 install_health_routes(_app) + install_api_docs_routes( + _app, + openapi_path=openapi_path, + enabled=lambda: bool(get_runtime_setting("API_DOCS_ENABLE")), + ) # 配置 CORS 中间件 _app.add_middleware( @@ -395,7 +405,7 @@ def create_app() -> FastAPI: verify_apikey=verify_apikey, prefix=f"{get_runtime_setting('API_V1_STR')}/plugin", protected_routes={ - f"{get_runtime_setting('API_V1_STR')}/openapi.json", + openapi_path, "/docs", "/docs/oauth2-redirect", "/redoc", diff --git a/app/runtime/config.py b/app/runtime/config.py index fed959dbf..77f753cec 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -790,6 +790,8 @@ class ConfigModel(BaseModel): ) # PassKey 是否强制用户验证(生物识别等) PASSKEY_REQUIRE_UV: bool = True + # 是否开放 API 文档;开放 /docs、/redoc 与 /api/v1/openapi.json,文档首次生成后常驻约 20–30MB 内存,修改后立即生效 + API_DOCS_ENABLE: bool = False # ==================== 工作流配置 ==================== # 工作流数据共享 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index aa2e744ae..61383db51 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -762,8 +762,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1085 | -| 内部导入边 | 9,198 | +| Python 模块 | 1086 | +| 内部导入边 | 9,199 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 48(债务已清零,48 条精确 containment) | @@ -773,7 +773,7 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,198,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。当前 `app.startup.lifecycle` 为 578、`app.factory` 为 590、`app.main` 为 592。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,199,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 578、`app.factory` 为 591、`app.main` 为 593。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 51e23483e..6c1ae41f5 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -341,7 +341,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所 - 成功和失败响应都只包含 `success`、`message`、`data` 三个顶层字段;各接口只有 `data` 的模型可以变化。 - 成功响应为 `{"success": true, "message": "", "data": <接口数据>}`。HTTP 错误保留原状态码,返回 `{"success": false, "message": <错误原因>, "data": null}`;请求参数校验错误会在 `data` 中附带结构化错误列表。 - 查询接口未命中但请求已正常完成时仍返回 `success=true`,存在性等业务状态通过 `data` 表达。例如 `/mediaserver/exists` 未命中时返回空的 `data.item`。 -- 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,调用方可从 `/docs` 或 `/api/v1/openapi.json` 查询数据结构。 +- 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,开放 API 文档后,调用方可从 `/docs` 或 `/api/v1/openapi.json` 查询数据结构。 - SSE、文件、图片、HTML、空响应,以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。 - 插件通过 `get_api()` 动态注册的 `/api/v1/plugin/...` 端点不属于主程序统一响应信封范围。插件自行声明响应模型、状态码和返回体,宿主只补充路径与鉴权依赖。 @@ -383,7 +383,7 @@ GitHub Token 是可选的管理员配置,可在设置页或首次初始化页 FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。 -交互式接口文档 `/docs` 读取 `/api/v1/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。 +交互式接口文档 `/docs`(Swagger UI)和 `/redoc` 读取 `/api/v1/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。这三个地址默认关闭,返回 404;在系统设置「高级设置 → 实验室」中打开「开放 API 文档」(`API_DOCS_ENABLE`)后立即可用,无需重启。文档在首次访问时生成,之后会常驻约 20–30MB 内存;关闭开关后,下一次文档请求会释放缓存的文档,其余内部缓存在重启后释放。镜像内置的 nginx 只转发 `/api` 路径,`/docs` 和 `/redoc` 需要直接访问后端端口。 #### 系统更新 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 4541e4d66..08f87021e 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -96,7 +96,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1085 / 9,198 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | +| 宿主 Python 模块 / 内部依赖边 | 1086 / 9,199 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 32e1a41b7..ee0973b47 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -930,8 +930,8 @@ "runtime_only": true } }, - "edge_count": 9198, - "edge_sha256": "a6b0717f35561891a28ba8da3ff6dad27aa8ffd7a1eb95007fc0ffa93ae12d56", + "edge_count": 9199, + "edge_sha256": "ad0a66de22bf8cbef0a94ebb5b3ebd1e857977f78139d1cf3c27f9290db14664", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -6596,6 +6596,7 @@ "app.factory -> app.adapters.observability.otel", "app.factory -> app.adapters.web", "app.factory -> app.adapters.web.correlation", + "app.factory -> app.adapters.web.docs", "app.factory -> app.adapters.web.health", "app.factory -> app.adapters.web.metrics", "app.factory -> app.adapters.web.plugin", @@ -10132,7 +10133,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1085, + "module_count": 1086, "modules": [ "app", "app.adapters", @@ -10181,6 +10182,7 @@ "app.adapters.system.update", "app.adapters.web", "app.adapters.web.correlation", + "app.adapters.web.docs", "app.adapters.web.health", "app.adapters.web.metrics", "app.adapters.web.plugin", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index b7ab8c88c..2b07a7046 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 590, + "loaded_app_module_count": 591, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 592, + "loaded_app_module_count": 593, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_api_docs_routes.py b/tests/test_api_docs_routes.py new file mode 100644 index 000000000..bd0baa48b --- /dev/null +++ b/tests/test_api_docs_routes.py @@ -0,0 +1,63 @@ +"""API 文档运行时开关的路由契约测试。""" + +import pytest +from fastapi.testclient import TestClient + +from app.application.settings.contract import build_setting_specs +from app.factory import create_app +from app.runtime.config import settings + +DOC_PATHS = ("/docs", "/docs/oauth2-redirect", "/redoc", "/api/v1/openapi.json") + + +def test_api_docs_are_closed_by_default() -> None: + """默认不开放文档,也不会因访问而生成文档。""" + app = create_app() + client = TestClient(app) + + for path in DOC_PATHS: + assert client.get(path).status_code == 404, path + assert app.openapi_schema is None + + +def test_api_docs_follow_runtime_toggle_without_restart(monkeypatch: pytest.MonkeyPatch) -> None: + """同一个应用实例上切换开关即时生效,关闭后丢弃已缓存的文档。""" + app = create_app() + client = TestClient(app) + monkeypatch.setattr(settings, "API_DOCS_ENABLE", True) + + openapi = client.get("/api/v1/openapi.json") + assert openapi.status_code == 200 + assert openapi.json()["openapi"].startswith("3.") + assert app.openapi_schema is not None + swagger = client.get("/docs") + assert swagger.status_code == 200 + assert "/api/v1/openapi.json" in swagger.text + assert "/docs/oauth2-redirect" in swagger.text + assert client.get("/docs/oauth2-redirect").status_code == 200 + redoc = client.get("/redoc") + assert redoc.status_code == 200 + assert "/api/v1/openapi.json" in redoc.text + + monkeypatch.setattr(settings, "API_DOCS_ENABLE", False) + assert client.get("/api/v1/openapi.json").status_code == 404 + assert app.openapi_schema is None + + +def test_api_docs_pages_keep_reverse_proxy_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + """文档页引用的 OpenAPI 地址带上 root_path,与 FastAPI 自带文档一致。""" + monkeypatch.setattr(settings, "API_DOCS_ENABLE", True) + client = TestClient(create_app(), root_path="/mp") + + assert "/mp/api/v1/openapi.json" in client.get("/docs").text + assert "/mp/api/v1/openapi.json" in client.get("/redoc").text + + +def test_api_docs_toggle_applies_immediately() -> None: + """开关位于需重启的安全分组,但逐请求读取,设置契约应标记为立即生效。""" + core_specs, _ = build_setting_specs() + spec = core_specs["API_DOCS_ENABLE"] + + assert spec.group == "security" + assert spec.apply_mode == "immediate" + assert core_specs["PASSKEY_REQUIRE_UV"].apply_mode == "restart_required" diff --git a/tests/test_health_probes.py b/tests/test_health_probes.py index 80fe23ac6..9f5bd01a4 100644 --- a/tests/test_health_probes.py +++ b/tests/test_health_probes.py @@ -58,7 +58,7 @@ def test_health_routes_are_public_but_hidden_from_business_openapi() -> None: client = TestClient(app) assert client.get("/health/live").status_code == 200 - schema = client.get("/api/v1/openapi.json").json() + schema = app.openapi() assert "/health/live" not in schema["paths"] assert "/health/ready" not in schema["paths"] diff --git a/tests/test_locale_helper.py b/tests/test_locale_helper.py index 771150b90..3a6b73b59 100644 --- a/tests/test_locale_helper.py +++ b/tests/test_locale_helper.py @@ -331,11 +331,11 @@ def test_http_exception_handler_returns_localized_response_envelope(): def test_application_docs_use_v1_openapi_and_backend_version(): - """默认接口文档应展示 v1 地址并使用真实后端版本号。""" + """接口文档应展示 v1 地址并使用真实后端版本号;是否开放由 API_DOCS_ENABLE 决定。""" app = create_app() - assert app.openapi_url == "/api/v1/openapi.json" assert app.version == APP_VERSION + assert app.openapi()["info"]["version"] == APP_VERSION assert "/api/v1/openapi.json" in {route.path for route in app.routes} assert "/api/v2/openapi.json" not in {route.path for route in app.routes}