Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/utils/server_util.py
T
insistence 2a055ba648 feat: 新增插件系统 (#112)
* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
2026-07-28 20:35:18 +08:00

590 lines
19 KiB
Python

import asyncio
import ipaddress
import os
import socket
import uuid
from collections.abc import Callable
import psutil
from fastapi import FastAPI, Request, applications
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse, JSONResponse
from redis import asyncio as aioredis
from config.env import AppConfig
class APIDocsUtil:
"""
API文档工具类
"""
# API文档URLs
_OPENAPI_URL = '/openapi.json'
_PROXY_OPENAPI_URL = '/proxy-openapi.json'
_DOCS_URL = '/docs'
_PROXY_DOCS_URL = '/proxy-docs'
_REDOC_URL = '/redoc'
_PROXY_REDOC_URL = '/proxy-redoc'
_OAUTH2_REDIRECT_URL = '/docs/oauth2-redirect'
_PROXY_OAUTH2_REDIRECT_URL = '/proxy-docs/oauth2-redirect'
# 文档静态资源URLs
DEFAULT_REDOC_JS_URL = 'https://registry.npmmirror.com/redoc/2/files/bundles/redoc.standalone.js'
DEFAULT_REDOC_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png'
DEFAULT_SWAGGER_JS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui-bundle.js'
DEFAULT_SWAGGER_CSS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui.css'
DEFAULT_SWAGGER_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png'
@classmethod
def proxy_openapi_url(cls) -> str:
"""
代理OpenAPI文档URL
"""
return cls._PROXY_OPENAPI_URL if not AppConfig.app_disable_swagger and not AppConfig.app_disable_redoc else None
@classmethod
def docs_url(cls) -> str:
"""
文档URL
"""
return cls._DOCS_URL
@classmethod
def proxy_docs_url(cls) -> str:
"""
代理文档URL
"""
return cls._PROXY_DOCS_URL if not AppConfig.app_disable_swagger else None
@classmethod
def redoc_url(cls) -> str:
"""
ReDoc文档URL
"""
return cls._REDOC_URL
@classmethod
def proxy_redoc_url(cls) -> str:
"""
代理ReDoc文档URL
"""
return cls._PROXY_REDOC_URL if not AppConfig.app_disable_redoc else None
@classmethod
def proxy_oauth2_redirect_url(cls) -> str:
"""
代理OAuth2重定向URL
"""
return cls._PROXY_OAUTH2_REDIRECT_URL if not AppConfig.app_disable_swagger else None
@classmethod
def setup_docs_static_resources(
cls,
redoc_js_url: str = DEFAULT_REDOC_JS_URL,
redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL,
swagger_js_url: str = DEFAULT_SWAGGER_JS_URL,
swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL,
swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL,
) -> None:
"""
配置文档静态资源
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
:param redoc_favicon_url: ReDoc要使用的favicon的URL
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
:param swagger_css_url: 用于加载Swagger UI CSS的URL
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
:return:
"""
def redoc_monkey_patch(*args, **kwargs) -> HTMLResponse:
return get_redoc_html(
*args,
**kwargs,
redoc_js_url=redoc_js_url,
redoc_favicon_url=redoc_favicon_url,
)
def swagger_ui_monkey_patch(*args, **kwargs) -> HTMLResponse:
return get_swagger_ui_html(
*args,
**kwargs,
swagger_js_url=swagger_js_url,
swagger_css_url=swagger_css_url,
swagger_favicon_url=swagger_favicon_url,
)
applications.get_redoc_html = redoc_monkey_patch
applications.get_swagger_ui_html = swagger_ui_monkey_patch
@classmethod
def custom_api_docs_router(
cls,
app: FastAPI,
redoc_js_url: str = DEFAULT_REDOC_JS_URL,
redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL,
swagger_js_url: str = DEFAULT_SWAGGER_JS_URL,
swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL,
swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL,
) -> None:
"""
自定义API文档路由
:param app: FastAPI对象
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
:param redoc_favicon_url: ReDoc要使用的favicon的URL
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
:param swagger_css_url: 用于加载Swagger UI CSS的URL
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
:return:
"""
async def custom_openapi(request: Request) -> JSONResponse:
return await cls._custom_openapi(app)
async def custom_redoc(request: Request) -> HTMLResponse:
return await cls._custom_redoc(app, redoc_js_url, redoc_favicon_url)
async def custom_swagger(request: Request) -> HTMLResponse:
return await cls._custom_swagger(app, swagger_js_url, swagger_css_url, swagger_favicon_url)
async def custom_swagger_ui_redirect(request: Request) -> HTMLResponse:
return await cls._custom_swagger_ui_redirect(app, swagger_favicon_url)
# 注册路由
app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False)
cls._register_docs_routes(app, custom_swagger, custom_swagger_ui_redirect, custom_redoc)
@classmethod
async def _custom_openapi(cls, app: FastAPI) -> JSONResponse:
"""
自定义 OpenAPI 路由处理函数
:param app: FastAPI对象
:return: openapi的json响应
"""
openapi_schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
summary=app.summary,
description=app.description,
terms_of_service=app.terms_of_service,
contact=app.contact,
license_info=app.license_info,
routes=app.routes,
webhooks=app.webhooks.routes,
tags=app.openapi_tags,
separate_input_output_schemas=app.separate_input_output_schemas,
external_docs=app.openapi_external_docs,
)
return JSONResponse(openapi_schema)
@classmethod
async def _custom_redoc(
cls,
app: FastAPI,
redoc_js_url: str,
redoc_favicon_url: str,
) -> HTMLResponse:
"""
自定义 ReDoc 路由处理函数
:param app: FastAPI对象
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
:param redoc_favicon_url: ReDoc要使用的favicon的URL
:return: ReDoc HTML响应
"""
if not AppConfig.app_disable_redoc:
return get_redoc_html(
openapi_url=cls._OPENAPI_URL,
title=f'{app.title} - ReDoc',
redoc_js_url=redoc_js_url,
redoc_favicon_url=redoc_favicon_url,
)
return cls._get_disabled_html_content(
f'{app.title} - ReDoc',
'ReDoc',
redoc_favicon_url,
)
@classmethod
async def _custom_swagger(
cls,
app: FastAPI,
swagger_js_url: str,
swagger_css_url: str,
swagger_favicon_url: str,
) -> HTMLResponse:
"""
自定义 Swagger UI 路由处理函数
:param app: FastAPI对象
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
:param swagger_css_url: 用于加载Swagger UI CSS的URL
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
:return: Swagger UI HTML响应
"""
if not AppConfig.app_disable_swagger:
return get_swagger_ui_html(
openapi_url=cls._OPENAPI_URL,
title=f'{app.title} - Swagger UI',
swagger_js_url=swagger_js_url,
swagger_css_url=swagger_css_url,
swagger_favicon_url=swagger_favicon_url,
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
init_oauth=app.swagger_ui_init_oauth,
swagger_ui_parameters=app.swagger_ui_parameters,
)
return cls._get_disabled_html_content(
f'{app.title} - Swagger UI',
'Swagger UI',
swagger_favicon_url,
)
@classmethod
async def _custom_swagger_ui_redirect(
cls,
app: FastAPI,
swagger_favicon_url: str,
) -> HTMLResponse:
"""
自定义 Swagger UI OAuth2 重定向路由处理函数
:param app: FastAPI对象
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
:return: Swagger UI OAuth2重定向HTML响应
"""
if not AppConfig.app_disable_swagger:
return get_swagger_ui_oauth2_redirect_html()
return cls._get_disabled_html_content(
f'{app.title} - Swagger UI OAuth2 Redirect',
'Swagger UI OAuth2 Redirect',
swagger_favicon_url,
)
@staticmethod
def _get_disabled_html_content(title: str, name: str, favicon_url: str) -> HTMLResponse:
"""
生成禁用文档的HTML内容
:param title: 页面标题
:param name: 文档名称
:param favicon_url: 图标地址
:return: 禁用文档HTML响应
"""
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="{favicon_url}">
<!--
{name} doesn't change outer page styles
-->
<style>
body {{
margin: 10px;
padding: 0;
}}
</style>
</head>
<body>
<noscript>
{name} requires Javascript to function. Please enable it to browse the documentation.
</noscript>
<h1 style="color: #ff4d4f;">{name} has been disabled. Please enable it first.</h1>
</body>
</html>
"""
return HTMLResponse(html)
@classmethod
def _register_docs_routes(
cls, app: FastAPI, swagger_handler: Callable, redirect_handler: Callable, redoc_handler: Callable
) -> None:
"""
注册文档路由
:param app: FastAPI对象
:param swagger_handler: Swagger UI 路由处理函数
:param redirect_handler: Swagger UI OAuth2 重定向路由处理函数
:param redoc_handler: ReDoc 路由处理函数
:return:
"""
swagger_urls: list[str] = (
[cls._DOCS_URL] if not AppConfig.app_disable_swagger else [cls._DOCS_URL, cls._PROXY_DOCS_URL]
)
swagger_redirect_urls: list[str] = (
[cls._OAUTH2_REDIRECT_URL]
if not AppConfig.app_disable_swagger
else [cls._OAUTH2_REDIRECT_URL, cls._PROXY_OAUTH2_REDIRECT_URL]
)
redoc_urls: list[str] = (
[cls._REDOC_URL] if not AppConfig.app_disable_redoc else [cls._REDOC_URL, cls._PROXY_REDOC_URL]
)
for url in swagger_urls:
app.add_route(url, swagger_handler, include_in_schema=False)
for url in swagger_redirect_urls:
app.add_route(url, redirect_handler, include_in_schema=False)
for url in redoc_urls:
app.add_route(url, redoc_handler, include_in_schema=False)
class StartupUtil:
"""
Application leader 租约工具类。
worker_id 仅用于日志展示,租约所有权使用进程唯一 owner token,避免多个
worker 配置相同展示 ID 时同时误判为 leader。
"""
_RENEW_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
end
return 0
"""
_RELEASE_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
"""
_application_lock_owner_token: str | None = None
_application_lock_owner_pid: int | None = None
@classmethod
def get_application_lock_owner_token(cls, worker_id: str) -> str:
"""
获取当前进程稳定且跨进程唯一的Application锁owner token。
:param worker_id: 日志展示用worker标识
:return: Application锁owner token
"""
process_id = os.getpid()
if cls._application_lock_owner_token is None or cls._application_lock_owner_pid != process_id:
cls._application_lock_owner_token = f'{worker_id}:{process_id}:{uuid.uuid4().hex}'
cls._application_lock_owner_pid = process_id
return cls._application_lock_owner_token
@staticmethod
async def acquire_application_leader(
redis: aioredis.Redis,
lock_key: str,
owner_token: str,
lock_expire_seconds: int,
) -> bool:
"""
获取Application leader租约。
:param redis: Redis连接对象
:param lock_key: 分布式锁key
:param owner_token: 当前进程唯一owner token
:param lock_expire_seconds: 锁过期时间
:return: 是否持有Application leader租约
"""
acquired = await redis.set(lock_key, owner_token, nx=True, ex=lock_expire_seconds)
if acquired:
return True
current_holder = await redis.get(lock_key)
return current_holder == owner_token
@classmethod
async def renew_application_leader(
cls,
redis: aioredis.Redis,
lock_key: str,
owner_token: str,
lock_expire_seconds: int,
) -> bool:
"""
仅在owner token匹配时原子续期Application leader租约。
:param redis: Redis连接对象
:param lock_key: 分布式锁key
:param owner_token: 当前进程唯一owner token
:param lock_expire_seconds: 锁过期时间
:return: 是否续期成功
"""
renewed = await redis.eval(
cls._RENEW_SCRIPT,
1,
lock_key,
owner_token,
lock_expire_seconds,
)
return bool(renewed)
@classmethod
async def release_application_leader(
cls,
redis: aioredis.Redis,
lock_key: str,
owner_token: str,
) -> bool:
"""
仅在owner token匹配时原子释放Application leader租约。
:param redis: Redis连接对象
:param lock_key: 分布式锁key
:param owner_token: 当前进程唯一owner token
:return: 是否释放成功
"""
released = await redis.eval(
cls._RELEASE_SCRIPT,
1,
lock_key,
owner_token,
)
return bool(released)
@classmethod
def start_application_leader_renewal(
cls,
redis: aioredis.Redis,
lock_key: str,
owner_token: str,
lock_expire_seconds: int,
interval_seconds: int,
on_lock_lost: Callable[[], None] | None = None,
) -> asyncio.Task:
"""
启动Application leader租约续期任务。
:param redis: Redis连接对象
:param lock_key: 分布式锁key
:param owner_token: 当前进程唯一owner token
:param lock_expire_seconds: 锁过期时间
:param interval_seconds: 续期间隔时间
:param on_lock_lost: 失去锁时的回调
:return: 异步任务对象
"""
async def _loop() -> None:
while True:
try:
renewed = await cls.renew_application_leader(
redis,
lock_key,
owner_token,
lock_expire_seconds,
)
if renewed:
await asyncio.sleep(interval_seconds)
continue
if on_lock_lost:
on_lock_lost()
break
except asyncio.CancelledError:
raise
except Exception:
# 无法确认续期成功时按租约丢失处理,避免TTL到期后继续以leader运行。
if on_lock_lost:
on_lock_lost()
break
return asyncio.create_task(_loop())
class WorkerIdUtil:
"""
Worker标识生成工具类
"""
_worker_id: str | None = None
@classmethod
def get_worker_id(cls, configured_worker_id: str | None) -> str:
"""
获取当前worker标识
:param configured_worker_id: 配置的worker标识
:return: 当前worker标识
"""
if cls._worker_id:
return cls._worker_id
worker_id = configured_worker_id
if not worker_id or worker_id.lower() == 'auto':
worker_id = f'{os.getpid()}-{uuid.uuid4().hex[:6]}'
cls._worker_id = worker_id
return worker_id
class IPUtil:
"""
IP工具类
"""
_PREFERRED_DNS_HOSTS: tuple[str, str] = ('223.5.5.5', '8.8.8.8')
_DNS_CONNECT_TIMEOUT = 1
@classmethod
def get_local_ip(cls) -> str:
"""
获取本机Local IP
"""
try:
for snics in psutil.net_if_addrs().values():
for snic in snics:
if snic.family == socket.AF_INET and snic.address.startswith('127.'):
return snic.address
except Exception:
pass
return '127.0.0.1'
@classmethod
def get_network_ips(cls) -> list[str]:
"""
获取本机Network IP列表
"""
network_ips = []
try:
# 获取网卡状态
stats = psutil.net_if_stats()
for name, snics in psutil.net_if_addrs().items():
# 过滤掉状态为DOWN的网卡
if name in stats and not stats[name].isup:
continue
for snic in snics:
if snic.family == socket.AF_INET:
try:
ip_obj = ipaddress.ip_address(snic.address)
if ip_obj.is_loopback or ip_obj.is_link_local:
continue
network_ips.append(snic.address)
except ValueError:
continue
except Exception:
pass
# 优先显示首选出站IP
preferred_ip = None
for dns_host in cls._PREFERRED_DNS_HOSTS:
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.settimeout(cls._DNS_CONNECT_TIMEOUT)
s.connect((dns_host, 80))
preferred_ip = s.getsockname()[0]
break
except Exception:
continue
if preferred_ip:
if preferred_ip in network_ips:
network_ips.remove(preferred_ip)
network_ips.insert(0, preferred_ip)
if not network_ips:
network_ips = ['127.0.0.1']
return network_ips