Files
FastapiAdmin/backend/app/core/discover.py
T
zhangtao e340053efa docs(readme): 更新项目说明与界面截图
补充移动端截图、Docker 部署说明与 Star History,同时更新后端技术栈版本和项目结构描述。
2026-09-06 20:52:11 +08:00

167 lines
6.9 KiB
Python
Raw 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.
"""简化的动态路由发现与注册。
目录与命名规范:
- 插件放在 ``app/plugin`` 下,顶级目录名以 ``module_`` 开头(如 ``module_example``)。
- 控制器文件必须为 ``controller.py``。
- 从 ``module_xxx`` 到 ``controller.py`` 的每级目录名须为合法 Python 标识符。
- 每级目录应有 ``__init__.py``(或符合 namespace package 规则)。
- 在 ``controller.py`` 模块顶层定义 ``APIRouter`` 实例并赋值给变量。
路由前缀:``module_xxx`` 映射为 ``/xxx``。
"""
import importlib
import re
from pathlib import Path
from fastapi import APIRouter, FastAPI
from app.core.logger import logger
# 路径参数只参与匹配、不参与语义,比较重复路由时先抹掉参数名
_PATH_PARAM_RE = re.compile(r"\{[^}]*\}")
class DynamicRouterRegistry:
"""动态路由注册器,管理插件路由的扫描与注册。"""
def __init__(self) -> None:
self._cache: APIRouter | None = None
def init_app(self, app: FastAPI) -> "DynamicRouterRegistry":
"""构建动态路由、注册到 app——返回 self 支持链式调用。"""
router = self._build()
app.include_router(router)
check_route_conflicts(app)
return self
def _build(self) -> APIRouter:
"""扫描并构建动态路由(带缓存)。
任何插件导入失败都会中止启动:半成品 router 会让进程带着缺失的接口正常
提供服务,故障被推迟成"某个模块突然 404",比启动即失败难定位得多。
"""
if self._cache is not None:
return self._cache
root_router = APIRouter()
seen_router_ids: set[int] = set()
base_package = importlib.import_module("app.plugin")
base_dir = Path(next(iter(base_package.__path__)))
controller_files = sorted(base_dir.glob("module_*/**/controller.py"))
container_routers: dict[str, APIRouter] = {}
failed: list[str] = []
for file in controller_files:
rel_path = file.relative_to(base_dir)
path_parts = rel_path.parts
top_module = path_parts[0]
suffix = top_module[7:] if top_module.startswith("module_") else ""
if not suffix:
logger.error(f"❌ 跳过异常顶级目录名(须为 module_ 前缀): {top_module!r},文件: {file}")
continue
prefix = f"/{suffix}"
if prefix not in container_routers:
container_routers[prefix] = APIRouter(prefix=prefix)
container_router = container_routers[prefix]
module_path = f"app.plugin.{'.'.join(path_parts[:-1])}.controller"
try:
module = importlib.import_module(module_path)
registered_here = 0
for attr_name in dir(module):
attr_value = getattr(module, attr_name, None)
if isinstance(attr_value, APIRouter):
router_id = id(attr_value)
if router_id not in seen_router_ids:
seen_router_ids.add(router_id)
container_router.include_router(attr_value)
registered_here += 1
if registered_here == 0:
logger.warning(
f"⚠️ 模块已加载但未注册任何路由: {module_path}\n"
f" 文件中未找到顶层 APIRouter 实例",
)
except Exception as e:
hint = _import_failure_hint(e)
logger.error(f"❌ 处理模块失败: {module_path}\n {hint}\n 异常: {e!s}")
failed.append(module_path)
if failed:
raise RuntimeError(
f"动态路由发现失败,{len(failed)} 个插件模块无法导入,已中止启动:\n " + "\n ".join(failed)
)
for prefix, container_router in sorted(container_routers.items()):
route_count = len(container_router.routes)
root_router.include_router(container_router)
if route_count == 0:
logger.warning(f"⚠️ 容器前缀 {prefix} 下未挂载任何子路由")
logger.info(f"✅ 动态注册路由: {prefix} (子路由数: {route_count})")
self._cache = root_router
return root_router
def check_route_conflicts(app: FastAPI) -> None:
"""启动期检测路由冲突:同一路径被注册两次时,先注册的会静默屏蔽后注册的。
FastAPI 按注册顺序首次匹配,冲突不会报错,只会让部分接口永远不可达——
通常来自插件自带 prefix 与内置模块撞车,或同一 router 被重复挂载。
"""
seen: dict[tuple[str, str], str] = {}
conflicts: list[str] = []
for route in app.routes:
methods = getattr(route, "methods", None)
path = getattr(route, "path", None)
if not methods or not path:
continue
# 参数名不参与匹配语义,/user/{id} 与 /user/{uid} 属于冲突
normalized = _PATH_PARAM_RE.sub("{}", path)
for method in sorted(methods):
key = (method, normalized)
endpoint = getattr(route, "name", None) or str(path)
if key in seen:
conflicts.append(f"{method} {path}{endpoint})已被 {seen[key]} 占用")
else:
seen[key] = endpoint
if conflicts:
raise RuntimeError(
f"检测到 {len(conflicts)} 处路由冲突,已中止启动(后注册者将永远不可达):\n "
+ "\n ".join(conflicts)
)
# 模块级单例
dynamic_router = DynamicRouterRegistry()
def _import_failure_hint(exc: BaseException) -> str:
"""根据异常类型给出简短排查提示。"""
if isinstance(exc, ModuleNotFoundError):
missing = getattr(exc, "name", None) or str(exc)
return (
f"无法解析模块(ModuleNotFoundError: {missing})。"
"常见原因:"
"① 从 app.plugin 到 controller 的某级目录缺少 __init__.py;"
"② 目录名不是合法 Python 标识符;"
"③ 磁盘路径与 import 路径不一致。"
)
if isinstance(exc, ImportError):
return "导入失败(ImportError),常见原因:循环导入、依赖未安装、或相对导入路径错误。"
if isinstance(exc, SyntaxError):
return f"controller.py 存在语法错误:{exc.msg}(约第 {exc.lineno} 行)。"
if isinstance(exc, PermissionError):
return (
"权限错误(PermissionError)。多见于受限环境:import 链上某模块初始化时调用了被禁止的系统能力。"
"在完整操作系统下重试;若仍失败再结合堆栈排查。"
)
return f"未分类异常({type(exc).__name__})。请查看下方堆栈排查。"