mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 21:15:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae1a43581 | ||
|
|
ee849f0854 | ||
|
|
5c9a27cc16 | ||
|
|
1b68854b84 | ||
|
|
b73585ebd2 | ||
|
|
28a6228556 | ||
|
|
dfce2ca094 | ||
|
|
326bdf9a17 | ||
|
|
cbe4e5ebe0 | ||
|
|
2d666e375f |
@@ -1,3 +1,26 @@
|
|||||||
|
<a id="v1.12.2"></a>
|
||||||
|
# [v1.12.2](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.2) - 2026-01-07
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Update changelog for v1.12.1 by [@wu-clan](https://github.com/wu-clan) in [#983](https://github.com/fastapi-practices/fastapi_best_architecture/pull/983)
|
||||||
|
* Fix environment variable file auto init by [@wu-clan](https://github.com/wu-clan) in [#985](https://github.com/fastapi-practices/fastapi_best_architecture/pull/985)
|
||||||
|
* Simplify the desensitization of operation log data by [@wu-clan](https://github.com/wu-clan) in [#987](https://github.com/fastapi-practices/fastapi_best_architecture/pull/987)
|
||||||
|
* Remove invalid configs of operation log by [@wu-clan](https://github.com/wu-clan) in [#988](https://github.com/fastapi-practices/fastapi_best_architecture/pull/988)
|
||||||
|
* Fix operation log queue status management by [@wu-clan](https://github.com/wu-clan) in [#989](https://github.com/fastapi-practices/fastapi_best_architecture/pull/989)
|
||||||
|
* Fix SQL scripts error in config plugin by [@wu-clan](https://github.com/wu-clan) in [#991](https://github.com/fastapi-practices/fastapi_best_architecture/pull/991)
|
||||||
|
* Fix the key of the refresh token removed by [@wu-clan](https://github.com/wu-clan) in [#993](https://github.com/fastapi-practices/fastapi_best_architecture/pull/993)
|
||||||
|
* Remove Linux Do OAuth2 login by [@wu-clan](https://github.com/wu-clan) in [#994](https://github.com/fastapi-practices/fastapi_best_architecture/pull/994)
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.2
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/wu-clan"><img src="https://wsrv.nl/?url=https%3A%2F%2Fgithub.com%2Fwu-clan.png&w=128&h=128&fit=cover&mask=circle" width="64" height="64" alt="@wu-clan"></a>
|
||||||
|
|
||||||
|
[Changes][v1.12.2]
|
||||||
|
|
||||||
|
|
||||||
<a id="v1.12.1"></a>
|
<a id="v1.12.1"></a>
|
||||||
# [v1.12.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.1) - 2025-12-31
|
# [v1.12.1](https://github.com/fastapi-practices/fastapi_best_architecture/releases/tag/v1.12.1) - 2025-12-31
|
||||||
|
|
||||||
@@ -1253,6 +1276,7 @@
|
|||||||
[Changes][v1.0.0]
|
[Changes][v1.0.0]
|
||||||
|
|
||||||
|
|
||||||
|
[v1.12.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.1...v1.12.2
|
||||||
[v1.12.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.1
|
[v1.12.1]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.12.0...v1.12.1
|
||||||
[v1.12.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
|
[v1.12.0]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.2...v1.12.0
|
||||||
[v1.11.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
|
[v1.11.2]: https://github.com/fastapi-practices/fastapi_best_architecture/compare/v1.11.1...v1.11.2
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from backend.utils.import_parse import get_all_models
|
from backend.utils.dynamic_import import get_all_models
|
||||||
|
|
||||||
# import all models for auto create db tables
|
# import all models for auto create db tables
|
||||||
for cls in get_all_models():
|
for cls in get_all_models():
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from backend.common.response.response_schema import ResponseModel, response_base
|
from backend.app.admin.schema.monitor import RedisCommandStat, RedisMonitorInfo, RedisServerInfo
|
||||||
|
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||||
from backend.common.security.jwt import DependsJwtAuth
|
from backend.common.security.jwt import DependsJwtAuth
|
||||||
from backend.utils.redis_info import redis_info
|
from backend.database.redis import redis_client
|
||||||
|
from backend.utils.format import fmt_seconds
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
|
@router.get('', summary='redis 监控', dependencies=[DependsJwtAuth])
|
||||||
async def get_redis_info() -> ResponseModel:
|
async def get_redis_info() -> ResponseSchemaModel[RedisMonitorInfo]:
|
||||||
data = {
|
info = await redis_client.info()
|
||||||
'info': await redis_info.get_info(),
|
db_size = await redis_client.dbsize()
|
||||||
'stats': await redis_info.get_stats(),
|
|
||||||
}
|
uptime_formatted = fmt_seconds(int(info.get('uptime_in_seconds', 0)))
|
||||||
|
|
||||||
|
server_info = RedisServerInfo(
|
||||||
|
redis_version=str(info.get('redis_version', '')),
|
||||||
|
redis_mode=str(info.get('redis_mode', '')),
|
||||||
|
os=str(info.get('os', '')),
|
||||||
|
arch_bits=str(info.get('arch_bits', '')),
|
||||||
|
tcp_port=str(info.get('tcp_port', '')),
|
||||||
|
uptime_in_seconds=uptime_formatted,
|
||||||
|
connected_clients=str(info.get('connected_clients', '')),
|
||||||
|
used_memory_human=str(info.get('used_memory_human', '')),
|
||||||
|
used_memory_peak_human=str(info.get('used_memory_peak_human', '')),
|
||||||
|
maxmemory_human=str(info.get('maxmemory_human', '0B')),
|
||||||
|
keys_num=str(db_size),
|
||||||
|
)
|
||||||
|
|
||||||
|
command_stats = await redis_client.info('commandstats')
|
||||||
|
stats_list = []
|
||||||
|
for key, value in command_stats.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
stats_list.append(RedisCommandStat(name=key.split('_')[-1], value=str(value.get('calls', '0'))))
|
||||||
|
|
||||||
|
data = RedisMonitorInfo(info=server_info, stats=stats_list)
|
||||||
return response_base.success(data=data)
|
return response_base.success(data=data)
|
||||||
|
|||||||
@@ -1,21 +1,134 @@
|
|||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone as tz
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from backend.common.response.response_schema import ResponseModel, response_base
|
from backend.app.admin.schema.monitor import (
|
||||||
|
CpuInfo,
|
||||||
|
DiskInfo,
|
||||||
|
MemInfo,
|
||||||
|
ServerMonitorInfo,
|
||||||
|
ServiceInfo,
|
||||||
|
SysInfo,
|
||||||
|
)
|
||||||
|
from backend.common.response.response_schema import ResponseSchemaModel, response_base
|
||||||
from backend.common.security.jwt import DependsJwtAuth
|
from backend.common.security.jwt import DependsJwtAuth
|
||||||
from backend.utils.server_info import server_info
|
from backend.utils.format import fmt_bytes, fmt_seconds
|
||||||
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
|
@router.get('', summary='server 监控', dependencies=[DependsJwtAuth])
|
||||||
async def get_server_info() -> ResponseModel:
|
async def get_server_info() -> ResponseSchemaModel[ServerMonitorInfo]: # noqa: C901
|
||||||
data = {
|
def get_all_info() -> ServerMonitorInfo: # noqa: C901
|
||||||
# 扔到线程池,避免阻塞
|
# CPU 信息
|
||||||
'cpu': await run_in_threadpool(server_info.get_cpu_info),
|
cpu_data = {
|
||||||
'mem': await run_in_threadpool(server_info.get_mem_info),
|
'usage': round(psutil.cpu_percent(interval=0.1), 2),
|
||||||
'sys': await run_in_threadpool(server_info.get_sys_info),
|
'logical_num': psutil.cpu_count(logical=True) or 0,
|
||||||
'disk': await run_in_threadpool(server_info.get_disk_info),
|
'physical_num': psutil.cpu_count(logical=False) or 0,
|
||||||
'service': await run_in_threadpool(server_info.get_service_info),
|
'max_freq': 0.0,
|
||||||
}
|
'min_freq': 0.0,
|
||||||
|
'current_freq': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(psutil, 'cpu_freq'):
|
||||||
|
cpu_freq = psutil.cpu_freq()
|
||||||
|
if cpu_freq:
|
||||||
|
cpu_data.update({
|
||||||
|
'max_freq': round(cpu_freq.max, 2),
|
||||||
|
'min_freq': round(cpu_freq.min, 2),
|
||||||
|
'current_freq': round(cpu_freq.current, 2),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cpu = CpuInfo(**cpu_data)
|
||||||
|
|
||||||
|
# 内存信息
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
gb_factor = 1024**3
|
||||||
|
mem_info = MemInfo(
|
||||||
|
total=round(mem.total / gb_factor, 2),
|
||||||
|
used=round(mem.used / gb_factor, 2),
|
||||||
|
free=round(mem.available / gb_factor, 2),
|
||||||
|
usage=round(mem.percent, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 系统信息
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
ip = '127.0.0.1'
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.settimeout(0.5)
|
||||||
|
s.connect(('8.8.8.8', 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except (TimeoutError, socket.gaierror, OSError):
|
||||||
|
pass
|
||||||
|
sys_info = SysInfo(name=hostname, ip=ip, os=platform.system(), arch=platform.machine())
|
||||||
|
|
||||||
|
# 磁盘信息
|
||||||
|
disk_list = []
|
||||||
|
exclude_fstypes = {'overlay', 'overlay2', 'tmpfs', 'devtmpfs', 'shm', 'proc', 'sysfs', 'cgroup', 'cgroup2'}
|
||||||
|
seen_devices = set()
|
||||||
|
for partition in psutil.disk_partitions(all=False):
|
||||||
|
# 跳过虚拟文件系统
|
||||||
|
if partition.fstype.lower() in exclude_fstypes:
|
||||||
|
continue
|
||||||
|
# 跳过重复设备(同一设备的不同挂载点)
|
||||||
|
if partition.device in seen_devices:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
usage = psutil.disk_usage(partition.mountpoint)
|
||||||
|
if usage:
|
||||||
|
seen_devices.add(partition.device)
|
||||||
|
disk_list.append(
|
||||||
|
DiskInfo(
|
||||||
|
dir=partition.mountpoint,
|
||||||
|
type=partition.fstype,
|
||||||
|
device=partition.device,
|
||||||
|
total=fmt_bytes(usage.total),
|
||||||
|
free=fmt_bytes(usage.free),
|
||||||
|
used=fmt_bytes(usage.used),
|
||||||
|
usage=f'{usage.percent:.2f}%',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 服务信息
|
||||||
|
process = psutil.Process(os.getpid())
|
||||||
|
proc_mem = process.memory_info()
|
||||||
|
try:
|
||||||
|
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
|
||||||
|
start_time = timezone.from_datetime(create_time)
|
||||||
|
except (psutil.NoSuchProcess, OSError):
|
||||||
|
start_time = timezone.now()
|
||||||
|
|
||||||
|
elapsed = fmt_seconds(round((timezone.now() - start_time).total_seconds()))
|
||||||
|
|
||||||
|
service = ServiceInfo(
|
||||||
|
name='Python3',
|
||||||
|
version=platform.python_version(),
|
||||||
|
home=sys.executable,
|
||||||
|
cpu_usage=f'{process.cpu_percent(interval=0.1):.2f}%',
|
||||||
|
mem_vms=fmt_bytes(proc_mem.vms),
|
||||||
|
mem_rss=fmt_bytes(proc_mem.rss),
|
||||||
|
mem_free=fmt_bytes(proc_mem.vms - proc_mem.rss),
|
||||||
|
startup=timezone.to_str(start_time),
|
||||||
|
elapsed=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ServerMonitorInfo(cpu=cpu, mem=mem_info, sys=sys_info, disk=disk_list, service=service)
|
||||||
|
|
||||||
|
data = await run_in_threadpool(get_all_info)
|
||||||
return response_base.success(data=data)
|
return response_base.success(data=data)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from backend.app.admin.schema.user import (
|
|||||||
UpdateUserParam,
|
UpdateUserParam,
|
||||||
)
|
)
|
||||||
from backend.app.admin.utils.password_security import get_hash_password
|
from backend.app.admin.utils.password_security import get_hash_password
|
||||||
from backend.utils.import_parse import import_module_cached
|
from backend.utils.dynamic_import import import_module_cached
|
||||||
from backend.utils.serializers import select_join_serialize
|
from backend.utils.serializers import select_join_serialize
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class LoginLog(DataClassBase):
|
|||||||
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
country: Mapped[str | None] = mapped_column(sa.String(64), comment='国家')
|
||||||
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
region: Mapped[str | None] = mapped_column(sa.String(64), comment='地区')
|
||||||
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
city: Mapped[str | None] = mapped_column(sa.String(64), comment='城市')
|
||||||
user_agent: Mapped[str | None] = mapped_column(sa.String(256), comment='请求头')
|
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
|
||||||
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
os: Mapped[str | None] = mapped_column(sa.String(64), comment='操作系统')
|
||||||
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
browser: Mapped[str | None] = mapped_column(sa.String(64), comment='浏览器')
|
||||||
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
device: Mapped[str | None] = mapped_column(sa.String(64), comment='设备')
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from backend.common.schema import SchemaBase
|
||||||
|
|
||||||
|
|
||||||
|
class CpuInfo(SchemaBase):
|
||||||
|
"""CPU 信息"""
|
||||||
|
|
||||||
|
usage: float = Field(description='CPU 使用率 (%)')
|
||||||
|
logical_num: int = Field(description='逻辑核心数')
|
||||||
|
physical_num: int = Field(description='物理核心数')
|
||||||
|
max_freq: float = Field(description='最大频率 (MHz)')
|
||||||
|
min_freq: float = Field(description='最小频率 (MHz)')
|
||||||
|
current_freq: float = Field(description='当前频率 (MHz)')
|
||||||
|
|
||||||
|
|
||||||
|
class MemInfo(SchemaBase):
|
||||||
|
"""内存信息"""
|
||||||
|
|
||||||
|
total: float = Field(description='总内存 (GB)')
|
||||||
|
used: float = Field(description='已使用内存 (GB)')
|
||||||
|
free: float = Field(description='可用内存 (GB)')
|
||||||
|
usage: float = Field(description='内存使用率 (%)')
|
||||||
|
|
||||||
|
|
||||||
|
class SysInfo(SchemaBase):
|
||||||
|
"""系统信息"""
|
||||||
|
|
||||||
|
name: str = Field(description='主机名')
|
||||||
|
ip: str = Field(description='IP 地址')
|
||||||
|
os: str = Field(description='操作系统')
|
||||||
|
arch: str = Field(description='系统架构')
|
||||||
|
|
||||||
|
|
||||||
|
class DiskInfo(SchemaBase):
|
||||||
|
"""磁盘信息"""
|
||||||
|
|
||||||
|
dir: str = Field(description='挂载点')
|
||||||
|
type: str = Field(description='文件系统类型')
|
||||||
|
device: str = Field(description='设备名称')
|
||||||
|
total: str = Field(description='总容量')
|
||||||
|
free: str = Field(description='可用容量')
|
||||||
|
used: str = Field(description='已使用容量')
|
||||||
|
usage: str = Field(description='使用率')
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfo(SchemaBase):
|
||||||
|
"""服务信息"""
|
||||||
|
|
||||||
|
name: str = Field(description='服务名称')
|
||||||
|
version: str = Field(description='版本')
|
||||||
|
home: str = Field(description='安装路径')
|
||||||
|
cpu_usage: str = Field(description='CPU 使用率')
|
||||||
|
mem_vms: str = Field(description='虚拟内存')
|
||||||
|
mem_rss: str = Field(description='物理内存')
|
||||||
|
mem_free: str = Field(description='可用内存')
|
||||||
|
startup: str = Field(description='启动时间')
|
||||||
|
elapsed: str = Field(description='运行时长')
|
||||||
|
|
||||||
|
|
||||||
|
class ServerMonitorInfo(SchemaBase):
|
||||||
|
"""服务器监控信息"""
|
||||||
|
|
||||||
|
cpu: CpuInfo = Field(description='CPU 信息')
|
||||||
|
mem: MemInfo = Field(description='内存信息')
|
||||||
|
sys: SysInfo = Field(description='系统信息')
|
||||||
|
disk: list[DiskInfo] = Field(description='磁盘信息列表')
|
||||||
|
service: ServiceInfo = Field(description='服务信息')
|
||||||
|
|
||||||
|
|
||||||
|
class RedisServerInfo(SchemaBase):
|
||||||
|
"""Redis 服务器信息"""
|
||||||
|
|
||||||
|
redis_version: str = Field(description='Redis 版本')
|
||||||
|
redis_mode: str = Field(description='运行模式')
|
||||||
|
os: str = Field(description='操作系统')
|
||||||
|
arch_bits: str = Field(description='架构位数')
|
||||||
|
tcp_port: str = Field(description='TCP 端口')
|
||||||
|
uptime_in_seconds: str = Field(description='运行时长')
|
||||||
|
connected_clients: str = Field(description='已连接客户端数')
|
||||||
|
used_memory_human: str = Field(description='已使用内存')
|
||||||
|
used_memory_peak_human: str = Field(description='内存使用峰值')
|
||||||
|
maxmemory_human: str = Field(description='最大内存限制')
|
||||||
|
keys_num: str = Field(description='键总数')
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCommandStat(SchemaBase):
|
||||||
|
"""Redis 命令统计"""
|
||||||
|
|
||||||
|
name: str = Field(description='命令名称')
|
||||||
|
value: str = Field(description='调用次数')
|
||||||
|
|
||||||
|
|
||||||
|
class RedisMonitorInfo(SchemaBase):
|
||||||
|
"""Redis 监控信息"""
|
||||||
|
|
||||||
|
info: RedisServerInfo = Field(description='服务器信息')
|
||||||
|
stats: list[RedisCommandStat] = Field(description='命令统计列表')
|
||||||
@@ -15,8 +15,8 @@ from backend.common.exception import errors
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.plugin.tools import uninstall_requirements_async
|
from backend.plugin.installer import install_git_plugin, install_zip_plugin
|
||||||
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
|
from backend.plugin.requirements import uninstall_requirements_async
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import random
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -92,7 +90,6 @@ class UserService:
|
|||||||
"""
|
"""
|
||||||
if await user_dao.get_by_username(db, obj.username):
|
if await user_dao.get_by_username(db, obj.username):
|
||||||
raise errors.ConflictError(msg='用户名已注册')
|
raise errors.ConflictError(msg='用户名已注册')
|
||||||
obj.nickname = obj.nickname or f'#{random.randrange(88888, 99999)}'
|
|
||||||
if not obj.password:
|
if not obj.password:
|
||||||
raise errors.RequestError(msg='密码不允许为空')
|
raise errors.RequestError(msg='密码不允许为空')
|
||||||
if not await dept_dao.get(db, obj.dept_id):
|
if not await dept_dao.get(db, obj.dept_id):
|
||||||
@@ -100,6 +97,7 @@ class UserService:
|
|||||||
for role_id in obj.roles:
|
for role_id in obj.roles:
|
||||||
if not await role_dao.get(db, role_id):
|
if not await role_dao.get(db, role_id):
|
||||||
raise errors.NotFoundError(msg='角色不存在')
|
raise errors.NotFoundError(msg='角色不存在')
|
||||||
|
obj.nickname = obj.nickname or obj.username
|
||||||
await user_dao.add(db, obj)
|
await user_dao.add(db, obj)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from backend.app.admin.crud.crud_user_password_history import user_password_hist
|
|||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.dynamic_config import load_user_security_config
|
from backend.utils.dynamic_config import load_user_security_config
|
||||||
from backend.utils.re_verify import is_has_letter, is_has_number, is_has_special_char
|
from backend.utils.pattern_validate import is_has_letter, is_has_number, is_has_special_char
|
||||||
|
|
||||||
password_hash = PasswordHash((BcryptHasher(),))
|
password_hash = PasswordHash((BcryptHasher(),))
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from backend.common.exception import errors
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import async_db_session
|
from backend.database.db import async_db_session
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.utils._await import run_await
|
from backend.utils.async_helper import run_await
|
||||||
from backend.utils.serializers import select_as_dict
|
from backend.utils.serializers import select_as_dict
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -33,10 +33,11 @@ from backend.core.path_conf import (
|
|||||||
)
|
)
|
||||||
from backend.database.db import async_db_session, create_tables, drop_tables
|
from backend.database.db import async_db_session, create_tables, drop_tables
|
||||||
from backend.database.redis import redis_client
|
from backend.database.redis import redis_client
|
||||||
from backend.plugin.tools import get_plugin_sql, get_plugins
|
from backend.plugin.core import get_plugin_sql, get_plugins
|
||||||
|
from backend.plugin.installer import install_git_plugin, install_zip_plugin
|
||||||
from backend.utils.console import console
|
from backend.utils.console import console
|
||||||
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script
|
from backend.utils.dynamic_import import import_module_cached
|
||||||
from backend.utils.import_parse import import_module_cached
|
from backend.utils.sql_parser import parse_sql_script
|
||||||
|
|
||||||
output_help = '\n更多信息,尝试 "[cyan]--help[/]"'
|
output_help = '\n更多信息,尝试 "[cyan]--help[/]"'
|
||||||
|
|
||||||
@@ -399,8 +400,8 @@ async def import_table(
|
|||||||
table_schema: str,
|
table_schema: str,
|
||||||
table_name: str,
|
table_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
from backend.plugin.code_generator.schema.code import ImportParam
|
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||||
from backend.plugin.code_generator.service.code_service import gen_service
|
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
||||||
@@ -414,7 +415,7 @@ async def import_table(
|
|||||||
|
|
||||||
async def generate() -> None:
|
async def generate() -> None:
|
||||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
from backend.plugin.code_generator.service.business_service import gen_business_service
|
||||||
from backend.plugin.code_generator.service.code_service import gen_service
|
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ids = []
|
ids = []
|
||||||
|
|||||||
+5
-37
@@ -1,25 +1,15 @@
|
|||||||
import glob
|
|
||||||
import json
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from starlette_context.errors import ContextDoesNotExistError
|
from starlette_context.errors import ContextDoesNotExistError
|
||||||
|
|
||||||
from backend.common.context import ctx
|
from backend.common.context import ctx
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import LOCALE_DIR
|
from backend.locale.loader import locale_loader
|
||||||
|
|
||||||
|
|
||||||
class I18n:
|
class I18n:
|
||||||
"""国际化管理器"""
|
"""国际化管理器"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.locales: dict[str, dict[str, Any]] = {}
|
|
||||||
self.load_locales()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_language(self) -> str:
|
def current_language(self) -> str:
|
||||||
"""获取当前请求的语言"""
|
"""获取当前请求的语言"""
|
||||||
@@ -33,29 +23,6 @@ class I18n:
|
|||||||
"""设置当前请求的语言"""
|
"""设置当前请求的语言"""
|
||||||
ctx.language = language
|
ctx.language = language
|
||||||
|
|
||||||
def load_locales(self) -> None:
|
|
||||||
"""加载语言文本"""
|
|
||||||
patterns = [
|
|
||||||
LOCALE_DIR / '*.json',
|
|
||||||
LOCALE_DIR / '*.yaml',
|
|
||||||
LOCALE_DIR / '*.yml',
|
|
||||||
]
|
|
||||||
|
|
||||||
lang_files = []
|
|
||||||
|
|
||||||
for pattern in patterns:
|
|
||||||
lang_files.extend(glob.glob(str(pattern)))
|
|
||||||
|
|
||||||
for lang_file in lang_files:
|
|
||||||
with open(lang_file, encoding='utf-8') as f:
|
|
||||||
lang = Path(lang_file).stem
|
|
||||||
file_type = Path(lang_file).suffix[1:]
|
|
||||||
match file_type:
|
|
||||||
case 'json':
|
|
||||||
self.locales[lang] = json.loads(f.read())
|
|
||||||
case 'yaml' | 'yml':
|
|
||||||
self.locales[lang] = yaml.full_load(f.read())
|
|
||||||
|
|
||||||
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
|
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
|
||||||
"""
|
"""
|
||||||
翻译函数
|
翻译函数
|
||||||
@@ -68,10 +35,10 @@ class I18n:
|
|||||||
keys = key.split('.')
|
keys = key.split('.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
translation = self.locales[self.current_language]
|
translation = locale_loader.locales[self.current_language]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
keys = 'error.language_not_found'
|
keys = 'error.language_not_found'.split('.')
|
||||||
translation = self.locales[settings.I18N_DEFAULT_LANGUAGE]
|
translation = locale_loader.locales[settings.I18N_DEFAULT_LANGUAGE]
|
||||||
|
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if isinstance(translation, dict) and k in list(translation.keys()):
|
if isinstance(translation, dict) and k in list(translation.keys()):
|
||||||
@@ -79,6 +46,7 @@ class I18n:
|
|||||||
else:
|
else:
|
||||||
# Pydantic 兼容
|
# Pydantic 兼容
|
||||||
translation = None if keys[0] == 'pydantic' else key
|
translation = None if keys[0] == 'pydantic' else key
|
||||||
|
break
|
||||||
|
|
||||||
if translation and kwargs:
|
if translation and kwargs:
|
||||||
translation = translation.format(**kwargs)
|
translation = translation.format(**kwargs)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from backend.common.context import ctx
|
|||||||
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
from backend.common.enums import RoleDataRuleExpressionType, RoleDataRuleOperatorType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.import_parse import get_all_models
|
from backend.utils.dynamic_import import get_all_models
|
||||||
|
|
||||||
|
|
||||||
class RequestPermission:
|
class RequestPermission:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from backend.common.exception import errors
|
|||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.common.security.jwt import DependsJwtAuth
|
from backend.common.security.jwt import DependsJwtAuth
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.utils.import_parse import import_module_cached
|
from backend.utils.dynamic_import import import_module_cached
|
||||||
|
|
||||||
|
|
||||||
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ UPLOAD_DIR = STATIC_DIR / 'upload'
|
|||||||
PLUGIN_DIR = BASE_PATH / 'plugin'
|
PLUGIN_DIR = BASE_PATH / 'plugin'
|
||||||
|
|
||||||
# 国际化文件目录
|
# 国际化文件目录
|
||||||
LOCALE_DIR = BASE_PATH / 'locale'
|
LOCALE_DIR = BASE_PATH / 'locale' / 'langs'
|
||||||
|
|
||||||
# MySQL 脚本目录
|
# MySQL 脚本目录
|
||||||
MYSQL_SCRIPT_DIR = BASE_PATH / 'sql' / 'mysql'
|
MYSQL_SCRIPT_DIR = BASE_PATH / 'sql' / 'mysql'
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ from backend.middleware.i18n_middleware import I18nMiddleware
|
|||||||
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
|
from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
|
||||||
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
from backend.middleware.opera_log_middleware import OperaLogMiddleware
|
||||||
from backend.middleware.state_middleware import StateMiddleware
|
from backend.middleware.state_middleware import StateMiddleware
|
||||||
from backend.plugin.tools import build_final_router
|
from backend.plugin.core import build_final_router
|
||||||
from backend.utils.demo_site import demo_site
|
from backend.utils.demo_mode import demo_site
|
||||||
from backend.utils.health_check import ensure_unique_route_names, http_limit_callback
|
from backend.utils.limiter import http_limit_callback
|
||||||
from backend.utils.openapi import simplify_operation_ids
|
from backend.utils.openapi import ensure_unique_route_names, simplify_operation_ids
|
||||||
from backend.utils.otel import init_otel
|
from backend.utils.otel import init_otel
|
||||||
from backend.utils.serializers import MsgSpecJSONResponse
|
from backend.utils.serializers import MsgSpecJSONResponse
|
||||||
from backend.utils.snowflake import snowflake
|
from backend.utils.snowflake import snowflake
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import glob
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from backend.core.path_conf import LOCALE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class LocaleLoader:
|
||||||
|
"""语言文件加载器"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.locales: dict[str, dict[str, Any]] = {}
|
||||||
|
self.load_locales()
|
||||||
|
|
||||||
|
def load_locales(self) -> None:
|
||||||
|
"""加载语言文本"""
|
||||||
|
patterns = [
|
||||||
|
LOCALE_DIR / '*.json',
|
||||||
|
LOCALE_DIR / '*.yaml',
|
||||||
|
LOCALE_DIR / '*.yml',
|
||||||
|
]
|
||||||
|
|
||||||
|
lang_files = []
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
lang_files.extend(glob.glob(str(pattern)))
|
||||||
|
|
||||||
|
for lang_file in lang_files:
|
||||||
|
with open(lang_file, encoding='utf-8') as f:
|
||||||
|
lang = Path(lang_file).stem
|
||||||
|
file_type = Path(lang_file).suffix[1:]
|
||||||
|
match file_type:
|
||||||
|
case 'json':
|
||||||
|
self.locales[lang] = json.loads(f.read())
|
||||||
|
case 'yaml' | 'yml':
|
||||||
|
self.locales[lang] = yaml.full_load(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
# 创建语言加载器单例
|
||||||
|
locale_loader = LocaleLoader()
|
||||||
+2
-1
@@ -2,7 +2,8 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from backend.core.registrar import register_app
|
from backend.core.registrar import register_app
|
||||||
from backend.plugin.tools import get_plugins, install_requirements
|
from backend.plugin.core import get_plugins
|
||||||
|
from backend.plugin.requirements import install_requirements
|
||||||
from backend.utils.console import console
|
from backend.utils.console import console
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from backend.common.i18n import i18n
|
from backend.common.i18n import i18n
|
||||||
|
from backend.core.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def get_current_language(request: Request) -> str | None:
|
def get_current_language(request: Request) -> str | None:
|
||||||
@@ -15,7 +16,7 @@ def get_current_language(request: Request) -> str | None:
|
|||||||
"""
|
"""
|
||||||
accept_language = request.headers.get('Accept-Language', '')
|
accept_language = request.headers.get('Accept-Language', '')
|
||||||
if not accept_language:
|
if not accept_language:
|
||||||
return None
|
return settings.I18N_DEFAULT_LANGUAGE
|
||||||
|
|
||||||
languages = [lang.split(';')[0] for lang in accept_language.split(',')]
|
languages = [lang.split(';')[0] for lang in accept_language.split(',')]
|
||||||
lang = languages[0].lower().strip()
|
lang = languages[0].lower().strip()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
from starlette.datastructures import UploadFile
|
from starlette.datastructures import UploadFile
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -150,7 +150,7 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def get_request_args(self, request: Request) -> dict[str, Any] | None:
|
async def get_request_args(self, request: Request) -> dict[str, Any] | None: # noqa: C901
|
||||||
"""
|
"""
|
||||||
获取请求参数
|
获取请求参数
|
||||||
|
|
||||||
@@ -162,12 +162,12 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
# 查询参数
|
# 查询参数
|
||||||
query_params = dict(request.query_params)
|
query_params = dict(request.query_params)
|
||||||
if query_params:
|
if query_params:
|
||||||
args['query_params'] = await self.desensitization(query_params)
|
args['query_params'] = self.desensitization(query_params)
|
||||||
|
|
||||||
# 路径参数
|
# 路径参数
|
||||||
path_params = request.path_params
|
path_params = request.path_params
|
||||||
if path_params:
|
if path_params:
|
||||||
args['path_params'] = await self.desensitization(path_params)
|
args['path_params'] = self.desensitization(path_params)
|
||||||
|
|
||||||
# Tip: .body() 必须在 .form() 之前获取
|
# Tip: .body() 必须在 .form() 之前获取
|
||||||
# https://github.com/encode/starlette/discussions/1933
|
# https://github.com/encode/starlette/discussions/1933
|
||||||
@@ -178,28 +178,66 @@ class OperaLogMiddleware(BaseHTTPMiddleware):
|
|||||||
if body_data:
|
if body_data:
|
||||||
# 注意:非 json 数据默认使用 data 作为键
|
# 注意:非 json 数据默认使用 data 作为键
|
||||||
if 'application/json' not in content_type:
|
if 'application/json' not in content_type:
|
||||||
args['data'] = str(body_data)
|
args['data'] = body_data.decode('utf-8', 'ignore') if isinstance(body_data, bytes) else str(body_data)
|
||||||
else:
|
else:
|
||||||
json_data = await request.json()
|
json_data = await request.json()
|
||||||
if isinstance(json_data, dict):
|
if isinstance(json_data, dict):
|
||||||
args['json'] = await self.desensitization(json_data)
|
args['json'] = self.desensitization(json_data)
|
||||||
else:
|
else:
|
||||||
args['data'] = str(body_data)
|
args['data'] = str(json_data)
|
||||||
|
|
||||||
# 表单参数
|
# 表单参数
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
if len(form_data) > 0:
|
if len(form_data) > 0:
|
||||||
|
serialized_form = {}
|
||||||
for k, v in form_data.items():
|
for k, v in form_data.items():
|
||||||
form_data = {k: v.filename} if isinstance(v, UploadFile) else {k: v}
|
if isinstance(v, UploadFile):
|
||||||
|
serialized_form[k] = {
|
||||||
|
'filename': v.filename,
|
||||||
|
'content_type': v.content_type,
|
||||||
|
'size': v.size,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
serialized_form[k] = v
|
||||||
if 'multipart/form-data' not in content_type:
|
if 'multipart/form-data' not in content_type:
|
||||||
args['x-www-form-urlencoded'] = await self.desensitization(form_data)
|
args['x-www-form-urlencoded'] = self.desensitization(serialized_form)
|
||||||
else:
|
else:
|
||||||
args['form-data'] = await self.desensitization(form_data)
|
args['form-data'] = self.desensitization(serialized_form)
|
||||||
|
|
||||||
|
if args:
|
||||||
|
args = self.truncate(args)
|
||||||
|
|
||||||
return args or None
|
return args or None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@sync_to_async
|
def truncate(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
截断处理
|
||||||
|
|
||||||
|
:param args: 需要截断的请求参数字典
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
max_size = 10240 # 数据最大大小(字节)
|
||||||
|
|
||||||
|
try:
|
||||||
|
args_str = json.dumps(args, ensure_ascii=False)
|
||||||
|
args_size = len(args_str.encode('utf-8'))
|
||||||
|
|
||||||
|
if args_size > max_size:
|
||||||
|
truncated_str = args_str[:max_size]
|
||||||
|
return {
|
||||||
|
'_truncated': True,
|
||||||
|
'_original_size': args_size,
|
||||||
|
'_max_size': max_size,
|
||||||
|
'_message': f'数据过大已截断:原始大小 {args_size} 字节,限制 {max_size} 字节',
|
||||||
|
'data_preview': truncated_str,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'请求参数截断处理失败:{e}')
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def desensitization(args: dict[str, Any]) -> dict[str, Any]:
|
def desensitization(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
脱敏处理
|
脱敏处理
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from backend.common.security.permission import RequestPermission
|
|||||||
from backend.common.security.rbac import DependsRBAC
|
from backend.common.security.rbac import DependsRBAC
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
from backend.database.db import CurrentSession, CurrentSessionTransaction
|
||||||
from backend.plugin.code_generator.schema.code import ImportParam
|
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||||
from backend.plugin.code_generator.service.code_service import gen_service
|
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -14,14 +14,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.core.path_conf import BASE_PATH
|
from backend.core.path_conf import BASE_PATH
|
||||||
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
|
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
|
||||||
from backend.plugin.code_generator.crud.crud_code import gen_dao
|
|
||||||
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
||||||
|
from backend.plugin.code_generator.crud.crud_gen import gen_dao
|
||||||
from backend.plugin.code_generator.model import GenBusiness
|
from backend.plugin.code_generator.model import GenBusiness
|
||||||
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam
|
from backend.plugin.code_generator.schema.business import CreateGenBusinessParam
|
||||||
from backend.plugin.code_generator.schema.code import ImportParam
|
|
||||||
from backend.plugin.code_generator.schema.column import CreateGenColumnParam
|
from backend.plugin.code_generator.schema.column import CreateGenColumnParam
|
||||||
|
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||||
from backend.plugin.code_generator.service.column_service import gen_column_service
|
from backend.plugin.code_generator.service.column_service import gen_column_service
|
||||||
from backend.plugin.code_generator.utils.code_template import gen_template
|
from backend.plugin.code_generator.utils.gen_template import gen_template
|
||||||
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
|
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from importlib.metadata import PackageNotFoundError, distribution
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import rtoml
|
import rtoml
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from packaging.requirements import Requirement
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
|
|
||||||
from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType
|
from backend.common.enums import DataBaseType, PrimaryKeyType, StatusType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
@@ -21,8 +16,8 @@ from backend.common.log import log
|
|||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
from backend.database.redis import RedisCli, redis_client
|
from backend.database.redis import RedisCli, redis_client
|
||||||
from backend.utils._await import run_await
|
from backend.utils.async_helper import run_await
|
||||||
from backend.utils.import_parse import get_model_objects, import_module_cached
|
from backend.utils.dynamic_import import get_model_objects, import_module_cached
|
||||||
|
|
||||||
|
|
||||||
class PluginConfigError(Exception):
|
class PluginConfigError(Exception):
|
||||||
@@ -33,10 +28,6 @@ class PluginInjectError(Exception):
|
|||||||
"""插件注入错误"""
|
"""插件注入错误"""
|
||||||
|
|
||||||
|
|
||||||
class PluginInstallError(Exception):
|
|
||||||
"""插件安装错误"""
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_plugins() -> list[str]:
|
def get_plugins() -> list[str]:
|
||||||
"""获取插件列表"""
|
"""获取插件列表"""
|
||||||
@@ -278,155 +269,6 @@ def build_final_router() -> APIRouter:
|
|||||||
return main_router
|
return main_router
|
||||||
|
|
||||||
|
|
||||||
def _ensure_pip_available() -> bool:
|
|
||||||
"""确保 pip 在虚拟环境中可用"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True
|
|
||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 尝试使用 ensurepip
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
[sys.executable, '-m', 'ensurepip', '--default-pip'],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True
|
|
||||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 尝试下载并安装
|
|
||||||
try:
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
||||||
with httpx.Client(timeout=3) as client:
|
|
||||||
get_pip_url = 'https://bootstrap.pypa.io/get-pip.py'
|
|
||||||
response = client.get(get_pip_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
f.write(response.text)
|
|
||||||
temp_file = f.name
|
|
||||||
except Exception: # noqa: ignore
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.check_call([sys.executable, temp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
result = subprocess.run([sys.executable, '-m', 'pip', '--version'], capture_output=True, text=True)
|
|
||||||
return result.returncode == 0
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(temp_file)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except Exception: # noqa: ignore
|
|
||||||
pass
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def install_requirements(plugin: str | None) -> None: # noqa: C901
|
|
||||||
"""
|
|
||||||
安装插件依赖
|
|
||||||
|
|
||||||
:param plugin: 指定插件名,否则检查所有插件
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
plugins = [plugin] if plugin else get_plugins()
|
|
||||||
|
|
||||||
for plugin in plugins:
|
|
||||||
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
|
|
||||||
missing_dependencies = False
|
|
||||||
if os.path.exists(requirements_file):
|
|
||||||
with open(requirements_file, encoding='utf-8') as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
req = Requirement(line)
|
|
||||||
dependency = req.name.lower()
|
|
||||||
except Exception as e:
|
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e
|
|
||||||
try:
|
|
||||||
distribution(dependency)
|
|
||||||
except PackageNotFoundError:
|
|
||||||
missing_dependencies = True
|
|
||||||
|
|
||||||
if missing_dependencies:
|
|
||||||
try:
|
|
||||||
if not _ensure_pip_available():
|
|
||||||
raise PluginInstallError(f'pip 安装失败,无法继续安装插件 {plugin} 依赖')
|
|
||||||
|
|
||||||
pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file]
|
|
||||||
if settings.PLUGIN_PIP_CHINA:
|
|
||||||
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
|
|
||||||
|
|
||||||
max_retries = settings.PLUGIN_PIP_MAX_RETRY
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
pip_install,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
if attempt == max_retries - 1:
|
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖安装超时')
|
|
||||||
continue
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
if attempt == max_retries - 1:
|
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
|
||||||
continue
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
|
||||||
|
|
||||||
|
|
||||||
def uninstall_requirements(plugin: str) -> None:
|
|
||||||
"""
|
|
||||||
卸载插件依赖
|
|
||||||
|
|
||||||
:param plugin: 插件名称
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
|
|
||||||
if os.path.exists(requirements_file):
|
|
||||||
try:
|
|
||||||
pip_uninstall = [sys.executable, '-m', 'pip', 'uninstall', '-r', requirements_file, '-y']
|
|
||||||
subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e
|
|
||||||
|
|
||||||
|
|
||||||
async def install_requirements_async(plugin: str | None = None) -> None:
|
|
||||||
"""
|
|
||||||
异步安装插件依赖
|
|
||||||
|
|
||||||
由于 Windows 平台限制,无法实现完美的全异步方案,详情:
|
|
||||||
https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows
|
|
||||||
"""
|
|
||||||
await run_in_threadpool(install_requirements, plugin)
|
|
||||||
|
|
||||||
|
|
||||||
async def uninstall_requirements_async(plugin: str) -> None:
|
|
||||||
"""
|
|
||||||
异步卸载插件依赖
|
|
||||||
|
|
||||||
:param plugin: 插件名称
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
await run_in_threadpool(uninstall_requirements, plugin)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginStatusChecker:
|
class PluginStatusChecker:
|
||||||
"""插件状态检查器"""
|
"""插件状态检查器"""
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from anyio import open_file
|
||||||
|
from dulwich import porcelain
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from backend.common.exception import errors
|
||||||
|
from backend.common.log import log
|
||||||
|
from backend.core.conf import settings
|
||||||
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
|
from backend.database.redis import redis_client
|
||||||
|
from backend.plugin.requirements import install_requirements_async
|
||||||
|
from backend.utils.pattern_validate import is_git_url
|
||||||
|
|
||||||
|
|
||||||
|
async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||||
|
"""
|
||||||
|
安装 ZIP 插件
|
||||||
|
|
||||||
|
:param file: FastAPI 上传文件对象或文件完整路径
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if isinstance(file, str):
|
||||||
|
async with await open_file(file, mode='rb') as fb:
|
||||||
|
contents = await fb.read()
|
||||||
|
else:
|
||||||
|
contents = await file.read()
|
||||||
|
file_bytes = io.BytesIO(contents)
|
||||||
|
if not zipfile.is_zipfile(file_bytes):
|
||||||
|
raise errors.RequestError(msg='插件压缩包格式非法')
|
||||||
|
with zipfile.ZipFile(file_bytes) as zf:
|
||||||
|
# 校验压缩包
|
||||||
|
plugin_namelist = zf.namelist()
|
||||||
|
plugin_dir_name = plugin_namelist[0].split('/')[0]
|
||||||
|
if not plugin_namelist:
|
||||||
|
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||||
|
if (
|
||||||
|
len(plugin_namelist) <= 3
|
||||||
|
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
|
||||||
|
or f'{plugin_dir_name}/README.md' not in plugin_namelist
|
||||||
|
):
|
||||||
|
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
|
||||||
|
|
||||||
|
# 插件是否可安装
|
||||||
|
plugin_name = re.match(
|
||||||
|
r'^([a-zA-Z0-9_]+)',
|
||||||
|
file.split(os.sep)[-1].split('.')[0].strip()
|
||||||
|
if isinstance(file, str)
|
||||||
|
else file.filename.split('.')[0].strip(),
|
||||||
|
).group()
|
||||||
|
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
|
||||||
|
if await full_plugin_path.exists():
|
||||||
|
raise errors.ConflictError(msg='此插件已安装')
|
||||||
|
await full_plugin_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 解压(安装)
|
||||||
|
members = []
|
||||||
|
for member in zf.infolist():
|
||||||
|
if member.filename.startswith(plugin_dir_name):
|
||||||
|
new_filename = member.filename.replace(plugin_dir_name, '')
|
||||||
|
if new_filename:
|
||||||
|
member.filename = new_filename
|
||||||
|
members.append(member)
|
||||||
|
zf.extractall(full_plugin_path, members)
|
||||||
|
|
||||||
|
await install_requirements_async(plugin_dir_name)
|
||||||
|
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||||
|
|
||||||
|
return plugin_name
|
||||||
|
|
||||||
|
|
||||||
|
async def install_git_plugin(repo_url: str) -> str:
|
||||||
|
"""
|
||||||
|
安装 Git 插件
|
||||||
|
|
||||||
|
:param repo_url:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
match = is_git_url(repo_url)
|
||||||
|
if not match:
|
||||||
|
raise errors.RequestError(msg='Git 仓库地址格式非法')
|
||||||
|
repo_name = match.group('repo')
|
||||||
|
path = anyio.Path(PLUGIN_DIR / repo_name)
|
||||||
|
if await path.exists():
|
||||||
|
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
|
||||||
|
try:
|
||||||
|
porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'插件安装失败: {e}')
|
||||||
|
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
|
||||||
|
|
||||||
|
await install_requirements_async(repo_name)
|
||||||
|
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||||
|
|
||||||
|
return repo_name
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError, distribution
|
||||||
|
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from backend.core.conf import settings
|
||||||
|
from backend.core.path_conf import PLUGIN_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstallError(Exception):
|
||||||
|
"""插件安装错误"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugins() -> list[str]:
|
||||||
|
"""
|
||||||
|
获取插件列表
|
||||||
|
|
||||||
|
注意:此函数从 backend.plugin.core 导入以避免循环依赖
|
||||||
|
"""
|
||||||
|
from backend.plugin.core import get_plugins as _get_plugins
|
||||||
|
|
||||||
|
return _get_plugins()
|
||||||
|
|
||||||
|
|
||||||
|
def install_requirements(plugin: str | None) -> None: # noqa: C901
|
||||||
|
"""
|
||||||
|
安装插件依赖
|
||||||
|
|
||||||
|
:param plugin: 指定插件名,否则检查所有插件
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
plugins = [plugin] if plugin else get_plugins()
|
||||||
|
|
||||||
|
for plugin in plugins:
|
||||||
|
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
|
||||||
|
missing_dependencies = False
|
||||||
|
if os.path.exists(requirements_file):
|
||||||
|
with open(requirements_file, encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
req = Requirement(line)
|
||||||
|
dependency = req.name.lower()
|
||||||
|
except Exception as e:
|
||||||
|
raise PluginInstallError(f'插件 {plugin} 依赖 {line} 格式错误: {e!s}') from e
|
||||||
|
try:
|
||||||
|
distribution(dependency)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
missing_dependencies = True
|
||||||
|
|
||||||
|
if missing_dependencies:
|
||||||
|
try:
|
||||||
|
pip_install = ['uv', 'pip', 'install', '-r', requirements_file]
|
||||||
|
if settings.PLUGIN_PIP_CHINA:
|
||||||
|
pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL])
|
||||||
|
|
||||||
|
max_retries = settings.PLUGIN_PIP_MAX_RETRY
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
subprocess.check_call(
|
||||||
|
pip_install,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise PluginInstallError(f'插件 {plugin} 依赖安装超时')
|
||||||
|
continue
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
||||||
|
continue
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise PluginInstallError(f'插件 {plugin} 依赖安装失败:{e}') from e
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_requirements(plugin: str) -> None:
|
||||||
|
"""
|
||||||
|
卸载插件依赖
|
||||||
|
|
||||||
|
:param plugin: 插件名称
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
requirements_file = PLUGIN_DIR / plugin / 'requirements.txt'
|
||||||
|
if os.path.exists(requirements_file):
|
||||||
|
try:
|
||||||
|
pip_uninstall = ['uv', 'pip', 'uninstall', '-r', str(requirements_file), '-y']
|
||||||
|
subprocess.check_call(pip_uninstall, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise PluginInstallError(f'插件 {plugin} 依赖卸载失败:{e}') from e
|
||||||
|
|
||||||
|
|
||||||
|
async def install_requirements_async(plugin: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
异步安装插件依赖
|
||||||
|
|
||||||
|
由于 Windows 平台限制,无法实现完美的全异步方案,详情:
|
||||||
|
https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows
|
||||||
|
"""
|
||||||
|
await run_in_threadpool(install_requirements, plugin)
|
||||||
|
|
||||||
|
|
||||||
|
async def uninstall_requirements_async(plugin: str) -> None:
|
||||||
|
"""
|
||||||
|
异步卸载插件依赖
|
||||||
|
|
||||||
|
:param plugin: 插件名称
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
await run_in_threadpool(uninstall_requirements, plugin)
|
||||||
@@ -87,6 +87,6 @@ def get_app_models() -> list[object]:
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_all_models() -> list[object]:
|
def get_all_models() -> list[object]:
|
||||||
"""获取所有模型类"""
|
"""获取所有模型类"""
|
||||||
from backend.plugin.tools import get_plugin_models
|
from backend.plugin.core import get_plugin_models
|
||||||
|
|
||||||
return get_app_models() + get_plugin_models()
|
return get_app_models() + get_plugin_models()
|
||||||
+1
-119
@@ -1,23 +1,11 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
from anyio import open_file
|
from anyio import open_file
|
||||||
from dulwich import porcelain
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from sqlparse import split
|
|
||||||
|
|
||||||
from backend.common.enums import FileType
|
from backend.common.enums import FileType
|
||||||
from backend.common.exception import errors
|
from backend.common.exception import errors
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.core.conf import settings
|
from backend.core.conf import settings
|
||||||
from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR
|
from backend.core.path_conf import UPLOAD_DIR
|
||||||
from backend.database.redis import redis_client
|
|
||||||
from backend.plugin.tools import install_requirements_async
|
|
||||||
from backend.utils.re_verify import is_git_url
|
|
||||||
from backend.utils.timezone import timezone
|
from backend.utils.timezone import timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -79,109 +67,3 @@ async def upload_file(file: UploadFile) -> str:
|
|||||||
raise errors.RequestError(msg='上传文件失败')
|
raise errors.RequestError(msg='上传文件失败')
|
||||||
await file.close()
|
await file.close()
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
|
||||||
async def install_zip_plugin(file: UploadFile | str) -> str:
|
|
||||||
"""
|
|
||||||
安装 ZIP 插件
|
|
||||||
|
|
||||||
:param file: FastAPI 上传文件对象或文件完整路径
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
if isinstance(file, str):
|
|
||||||
async with await open_file(file, mode='rb') as fb:
|
|
||||||
contents = await fb.read()
|
|
||||||
else:
|
|
||||||
contents = await file.read()
|
|
||||||
file_bytes = io.BytesIO(contents)
|
|
||||||
if not zipfile.is_zipfile(file_bytes):
|
|
||||||
raise errors.RequestError(msg='插件压缩包格式非法')
|
|
||||||
with zipfile.ZipFile(file_bytes) as zf:
|
|
||||||
# 校验压缩包
|
|
||||||
plugin_namelist = zf.namelist()
|
|
||||||
plugin_dir_name = plugin_namelist[0].split('/')[0]
|
|
||||||
if not plugin_namelist:
|
|
||||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
|
||||||
if (
|
|
||||||
len(plugin_namelist) <= 3
|
|
||||||
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
|
|
||||||
or f'{plugin_dir_name}/README.md' not in plugin_namelist
|
|
||||||
):
|
|
||||||
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
|
|
||||||
|
|
||||||
# 插件是否可安装
|
|
||||||
plugin_name = re.match(
|
|
||||||
r'^([a-zA-Z0-9_]+)',
|
|
||||||
file.split(os.sep)[-1].split('.')[0].strip()
|
|
||||||
if isinstance(file, str)
|
|
||||||
else file.filename.split('.')[0].strip(),
|
|
||||||
).group()
|
|
||||||
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
|
|
||||||
if await full_plugin_path.exists():
|
|
||||||
raise errors.ConflictError(msg='此插件已安装')
|
|
||||||
await full_plugin_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 解压(安装)
|
|
||||||
members = []
|
|
||||||
for member in zf.infolist():
|
|
||||||
if member.filename.startswith(plugin_dir_name):
|
|
||||||
new_filename = member.filename.replace(plugin_dir_name, '')
|
|
||||||
if new_filename:
|
|
||||||
member.filename = new_filename
|
|
||||||
members.append(member)
|
|
||||||
zf.extractall(full_plugin_path, members)
|
|
||||||
|
|
||||||
await install_requirements_async(plugin_dir_name)
|
|
||||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
|
||||||
|
|
||||||
return plugin_name
|
|
||||||
|
|
||||||
|
|
||||||
async def install_git_plugin(repo_url: str) -> str:
|
|
||||||
"""
|
|
||||||
安装 Git 插件
|
|
||||||
|
|
||||||
:param repo_url:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
match = is_git_url(repo_url)
|
|
||||||
if not match:
|
|
||||||
raise errors.RequestError(msg='Git 仓库地址格式非法')
|
|
||||||
repo_name = match.group('repo')
|
|
||||||
path = anyio.Path(PLUGIN_DIR / repo_name)
|
|
||||||
if await path.exists():
|
|
||||||
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
|
|
||||||
try:
|
|
||||||
porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f'插件安装失败: {e}')
|
|
||||||
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
|
|
||||||
|
|
||||||
await install_requirements_async(repo_name)
|
|
||||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
|
||||||
|
|
||||||
return repo_name
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_sql_script(filepath: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
解析 SQL 脚本
|
|
||||||
|
|
||||||
:param filepath: 脚本文件路径
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
path = anyio.Path(filepath)
|
|
||||||
if not await path.exists():
|
|
||||||
raise errors.NotFoundError(msg='SQL 脚本文件不存在')
|
|
||||||
|
|
||||||
async with await open_file(filepath, encoding='utf-8') as f:
|
|
||||||
contents = await f.read(1024)
|
|
||||||
while additional_contents := await f.read(1024):
|
|
||||||
contents += additional_contents
|
|
||||||
|
|
||||||
statements = split(contents)
|
|
||||||
for statement in statements:
|
|
||||||
if not any(statement.lower().startswith(_) for _ in ['select', 'insert']):
|
|
||||||
raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT')
|
|
||||||
|
|
||||||
return statements
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
def fmt_seconds(seconds: int) -> str:
|
||||||
|
"""格式化秒数为可读的时间字符串"""
|
||||||
|
days, rem = divmod(int(seconds), 86400)
|
||||||
|
hours, rem = divmod(rem, 3600)
|
||||||
|
minutes, secs = divmod(rem, 60)
|
||||||
|
parts = []
|
||||||
|
if days:
|
||||||
|
parts.append(f'{days} 天')
|
||||||
|
if hours:
|
||||||
|
parts.append(f'{hours} 小时')
|
||||||
|
if minutes:
|
||||||
|
parts.append(f'{minutes} 分钟')
|
||||||
|
if secs:
|
||||||
|
parts.append(f'{secs} 秒')
|
||||||
|
return ' '.join(parts) if parts else '0 秒'
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_bytes(size: float) -> str:
|
||||||
|
s, factor = size, 1024
|
||||||
|
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
|
||||||
|
if abs(s) < factor:
|
||||||
|
return f'{s:.2f} {unit}B'
|
||||||
|
s /= factor
|
||||||
|
return f'{s:.2f} YB'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from backend.common.exception import errors
|
||||||
|
from backend.common.response.response_code import StandardResponseCode
|
||||||
|
|
||||||
|
|
||||||
|
async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029
|
||||||
|
"""
|
||||||
|
请求限制时的默认回调函数
|
||||||
|
|
||||||
|
:param request: FastAPI 请求对象
|
||||||
|
:param response: FastAPI 响应对象
|
||||||
|
:param expire: 剩余毫秒数
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
expires = ceil(expire / 1000)
|
||||||
|
raise errors.HTTPError(
|
||||||
|
code=StandardResponseCode.HTTP_429,
|
||||||
|
msg='请求过于频繁,请稍后重试',
|
||||||
|
headers={'Retry-After': str(expires)},
|
||||||
|
)
|
||||||
@@ -12,3 +12,18 @@ def simplify_operation_ids(app: FastAPI) -> None:
|
|||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, APIRoute):
|
if isinstance(route, APIRoute):
|
||||||
route.operation_id = route.name
|
route.operation_id = route.name
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_unique_route_names(app: FastAPI) -> None:
|
||||||
|
"""
|
||||||
|
检查路由名称是否唯一
|
||||||
|
|
||||||
|
:param app: FastAPI 应用实例
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
temp_routes = set()
|
||||||
|
for route in app.routes:
|
||||||
|
if isinstance(route, APIRoute):
|
||||||
|
if route.name in temp_routes:
|
||||||
|
raise ValueError(f'Non-unique route name: {route.name}')
|
||||||
|
temp_routes.add(route.name)
|
||||||
|
|||||||
@@ -3,47 +3,9 @@ import functools
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from math import ceil
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
from backend.common.exception import errors
|
|
||||||
from backend.common.log import log
|
from backend.common.log import log
|
||||||
from backend.common.response.response_code import StandardResponseCode
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_unique_route_names(app: FastAPI) -> None:
|
|
||||||
"""
|
|
||||||
检查路由名称是否唯一
|
|
||||||
|
|
||||||
:param app: FastAPI 应用实例
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
temp_routes = set()
|
|
||||||
for route in app.routes:
|
|
||||||
if isinstance(route, APIRoute):
|
|
||||||
if route.name in temp_routes:
|
|
||||||
raise ValueError(f'Non-unique route name: {route.name}')
|
|
||||||
temp_routes.add(route.name)
|
|
||||||
|
|
||||||
|
|
||||||
async def http_limit_callback(request: Request, response: Response, expire: int) -> None: # noqa: RUF029
|
|
||||||
"""
|
|
||||||
请求限制时的默认回调函数
|
|
||||||
|
|
||||||
:param request: FastAPI 请求对象
|
|
||||||
:param response: FastAPI 响应对象
|
|
||||||
:param expire: 剩余毫秒数
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
expires = ceil(expire / 1000)
|
|
||||||
raise errors.HTTPError(
|
|
||||||
code=StandardResponseCode.HTTP_429,
|
|
||||||
msg='请求过于频繁,请稍后重试',
|
|
||||||
headers={'Retry-After': str(expires)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def timer(func) -> Callable: # noqa: ANN001
|
def timer(func) -> Callable: # noqa: ANN001
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
from backend.database.redis import redis_client
|
|
||||||
from backend.utils.server_info import server_info
|
|
||||||
|
|
||||||
|
|
||||||
class RedisInfo:
|
|
||||||
@staticmethod
|
|
||||||
async def get_info() -> dict[str, str]:
|
|
||||||
"""获取 Redis 服务器信息"""
|
|
||||||
|
|
||||||
# 获取原始信息
|
|
||||||
info = await redis_client.info()
|
|
||||||
|
|
||||||
# 格式化信息
|
|
||||||
fmt_info: dict[str, str] = {}
|
|
||||||
for key, value in info.items():
|
|
||||||
if isinstance(value, dict):
|
|
||||||
# 将字典格式化为字符串
|
|
||||||
fmt_info[key] = ','.join(f'{k}={v}' for k, v in value.items())
|
|
||||||
else:
|
|
||||||
fmt_info[key] = str(value)
|
|
||||||
|
|
||||||
# 添加数据库大小信息
|
|
||||||
db_size = await redis_client.dbsize()
|
|
||||||
fmt_info['keys_num'] = str(db_size)
|
|
||||||
|
|
||||||
# 格式化运行时间
|
|
||||||
uptime = int(fmt_info.get('uptime_in_seconds', '0'))
|
|
||||||
fmt_info['uptime_in_seconds'] = server_info.fmt_seconds(uptime)
|
|
||||||
|
|
||||||
return fmt_info
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_stats() -> list[dict[str, str]]:
|
|
||||||
"""获取 Redis 命令统计信息"""
|
|
||||||
|
|
||||||
# 获取命令统计信息
|
|
||||||
command_stats = await redis_client.info('commandstats')
|
|
||||||
|
|
||||||
# 格式化统计信息
|
|
||||||
stats_list: list[dict[str, str]] = []
|
|
||||||
for key, value in command_stats.items():
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
command_name = key.split('_')[-1]
|
|
||||||
call_count = str(value.get('calls', '0'))
|
|
||||||
stats_list.append({'name': command_name, 'value': call_count})
|
|
||||||
|
|
||||||
return stats_list
|
|
||||||
|
|
||||||
|
|
||||||
redis_info: RedisInfo = RedisInfo()
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import os
|
|
||||||
import platform
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from datetime import timezone as tz
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
from backend.utils.timezone import timezone
|
|
||||||
|
|
||||||
|
|
||||||
class ServerInfo:
|
|
||||||
@staticmethod
|
|
||||||
def format_bytes(size: float) -> str:
|
|
||||||
"""
|
|
||||||
格式化字节大小
|
|
||||||
|
|
||||||
:param size: 字节大小
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
factor = 1024
|
|
||||||
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
|
|
||||||
if abs(size) < factor:
|
|
||||||
return f'{size:.2f} {unit}B'
|
|
||||||
size /= factor
|
|
||||||
return f'{size:.2f} YB'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fmt_seconds(seconds: int) -> str:
|
|
||||||
"""
|
|
||||||
格式化秒数为可读的时间字符串
|
|
||||||
|
|
||||||
:param seconds: 秒数
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
days, rem = divmod(int(seconds), 86400)
|
|
||||||
hours, rem = divmod(rem, 3600)
|
|
||||||
minutes, seconds = divmod(rem, 60)
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
if days:
|
|
||||||
parts.append(f'{days} 天')
|
|
||||||
if hours:
|
|
||||||
parts.append(f'{hours} 小时')
|
|
||||||
if minutes:
|
|
||||||
parts.append(f'{minutes} 分钟')
|
|
||||||
if seconds:
|
|
||||||
parts.append(f'{seconds} 秒')
|
|
||||||
|
|
||||||
return ' '.join(parts) if parts else '0 秒'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fmt_timedelta(td: timedelta) -> str:
|
|
||||||
"""
|
|
||||||
格式化时间差
|
|
||||||
|
|
||||||
:param td: 时间差对象
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
return ServerInfo.fmt_seconds(round(td.total_seconds()))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_cpu_info() -> dict[str, float | int]:
|
|
||||||
"""获取 CPU 信息"""
|
|
||||||
cpu_info = {
|
|
||||||
'usage': round(psutil.cpu_percent(interval=0.1), 2), # %
|
|
||||||
'logical_num': psutil.cpu_count(logical=True) or 0,
|
|
||||||
'physical_num': psutil.cpu_count(logical=False) or 0,
|
|
||||||
'max_freq': 0.0,
|
|
||||||
'min_freq': 0.0,
|
|
||||||
'current_freq': 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
if hasattr(psutil, 'cpu_freq'):
|
|
||||||
cpu_freq = psutil.cpu_freq()
|
|
||||||
if cpu_freq: # Some systems return None
|
|
||||||
cpu_info.update({
|
|
||||||
'max_freq': round(cpu_freq.max, 2),
|
|
||||||
'min_freq': round(cpu_freq.min, 2),
|
|
||||||
'current_freq': round(cpu_freq.current, 2),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return cpu_info
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_mem_info() -> dict[str, float]:
|
|
||||||
"""获取内存信息"""
|
|
||||||
mem = psutil.virtual_memory()
|
|
||||||
gb_factor = 1024**3
|
|
||||||
return {
|
|
||||||
'total': round(mem.total / gb_factor, 2),
|
|
||||||
'used': round(mem.used / gb_factor, 2),
|
|
||||||
'free': round(mem.available / gb_factor, 2),
|
|
||||||
'usage': round(mem.percent, 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_sys_info() -> dict[str, str]:
|
|
||||||
"""获取服务器信息"""
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
ip = '127.0.0.1'
|
|
||||||
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
||||||
s.settimeout(0.5)
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
ip = s.getsockname()[0]
|
|
||||||
except (TimeoutError, socket.gaierror, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
'name': hostname,
|
|
||||||
'ip': ip,
|
|
||||||
'os': platform.system(),
|
|
||||||
'arch': platform.machine(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_disk_info() -> list[dict[str, str]]:
|
|
||||||
"""获取磁盘信息"""
|
|
||||||
disk_info = []
|
|
||||||
for partition in psutil.disk_partitions(all=False):
|
|
||||||
usage = psutil.disk_usage(partition.mountpoint)
|
|
||||||
if usage:
|
|
||||||
disk_info.append({
|
|
||||||
'dir': partition.mountpoint,
|
|
||||||
'type': partition.fstype,
|
|
||||||
'device': partition.device,
|
|
||||||
'total': ServerInfo.format_bytes(usage.total),
|
|
||||||
'free': ServerInfo.format_bytes(usage.free),
|
|
||||||
'used': ServerInfo.format_bytes(usage.used),
|
|
||||||
'usage': f'{usage.percent:.2f}%',
|
|
||||||
})
|
|
||||||
return disk_info
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_service_info() -> dict[str, str | datetime]:
|
|
||||||
"""获取服务信息"""
|
|
||||||
process = psutil.Process(os.getpid())
|
|
||||||
mem_info = process.memory_info()
|
|
||||||
|
|
||||||
try:
|
|
||||||
create_time = datetime.fromtimestamp(process.create_time(), tz=tz.utc)
|
|
||||||
start_time = timezone.from_datetime(create_time)
|
|
||||||
except (psutil.NoSuchProcess, OSError):
|
|
||||||
start_time = timezone.now()
|
|
||||||
|
|
||||||
elapsed = ServerInfo.fmt_timedelta(timezone.now() - start_time)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'name': 'Python3',
|
|
||||||
'version': platform.python_version(),
|
|
||||||
'home': sys.executable,
|
|
||||||
'cpu_usage': f'{process.cpu_percent(interval=0.1):.2f}%',
|
|
||||||
'mem_vms': ServerInfo.format_bytes(mem_info.vms),
|
|
||||||
'mem_rss': ServerInfo.format_bytes(mem_info.rss),
|
|
||||||
'mem_free': ServerInfo.format_bytes(mem_info.vms - mem_info.rss),
|
|
||||||
'startup': timezone.to_str(start_time),
|
|
||||||
'elapsed': elapsed,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
server_info: ServerInfo = ServerInfo()
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import anyio
|
||||||
|
|
||||||
|
from anyio import open_file
|
||||||
|
from sqlparse import split
|
||||||
|
|
||||||
|
from backend.common.exception import errors
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_sql_script(filepath: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
解析 SQL 脚本
|
||||||
|
|
||||||
|
:param filepath: 脚本文件路径
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
path = anyio.Path(filepath)
|
||||||
|
if not await path.exists():
|
||||||
|
raise errors.NotFoundError(msg='SQL 脚本文件不存在')
|
||||||
|
|
||||||
|
async with await open_file(filepath, encoding='utf-8') as f:
|
||||||
|
contents = await f.read(1024)
|
||||||
|
while additional_contents := await f.read(1024):
|
||||||
|
contents += additional_contents
|
||||||
|
|
||||||
|
statements = split(contents)
|
||||||
|
for statement in statements:
|
||||||
|
if not any(statement.lower().startswith(_) for _ in ['select', 'insert']):
|
||||||
|
raise errors.RequestError(msg='SQL 脚本文件中存在非法操作,仅允许 SELECT 和 INSERT')
|
||||||
|
|
||||||
|
return statements
|
||||||
Reference in New Issue
Block a user