feat(api): serve API docs behind a runtime toggle, closed by default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-09-30 17:52:58 +08:00
committed by InfinityPacer
parent b6f3d37bd1
commit 8f9754b5f8
13 changed files with 239 additions and 63 deletions

90
app/adapters/web/docs.py Normal file
View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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": "日志写入等待超时时间,单位秒",

View File

@@ -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",

View File

@@ -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
# ==================== 工作流配置 ====================
# 工作流数据共享

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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` 需要直接访问后端端口。
#### 系统更新

View File

@@ -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 等基线均为零 |

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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}