Compare commits

..
10 Commits
Author SHA1 Message Date
Wu Clan 8ae1a43581 Update i18n language storage and loading (#1008) 2026-01-13 19:10:41 +08:00
Wu Clan ee849f0854 Update the plugin dependency install method (#1007) 2026-01-13 16:14:41 +08:00
Wu Clan 5c9a27cc16 Update nickname generation when create user (#1004)
* Update nickname generation when create user

* Fix lint

* Update code style
2026-01-12 19:03:02 +08:00
Wu Clan 1b68854b84 Update code generation part file naming (#1002) 2026-01-11 18:03:20 +08:00
Wu Clan b73585ebd2 Optimize definitions of multiple utility functions (#1001)
* Optimize definitions of multiple utility functions

* Update plugin tools
2026-01-11 16:57:52 +08:00
Wu Clan 28a6228556 Update redis and server monitor implementations (#1000)
* Update redis and server monitor implementations

* Fix server disk information
2026-01-11 13:21:37 +08:00
Wu Clan dfce2ca094 Remove the opera log desensitization asynchronous (#999) 2026-01-10 18:30:16 +08:00
shj366 326bdf9a17 Fix opera log non-json data overload (#998)
* fix: skip non-json body logging for multipart uploads

* Update truncation implementation
2026-01-10 18:23:02 +08:00
Wu Clan cbe4e5ebe0 Update login log request header column length (#996) 2026-01-08 12:23:37 +08:00
Wu Clan 2d666e375f Update the changelog for v1.12.2 (#995) 2026-01-07 12:23:47 +08:00
44 changed files with 718 additions and 634 deletions
+24
View File
@@ -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
View File
@@ -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():
+31 -7
View File
@@ -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)
+124 -11
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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='设备')
+98
View File
@@ -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='命令统计列表')
+2 -2
View File
@@ -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 -3
View File
@@ -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
+1 -1
View File
@@ -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(),))
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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'
+4 -4
View File
@@ -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
View File
+44
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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()
+49 -11
View File
@@ -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]:
""" """
脱敏处理 脱敏处理
+2 -2
View File
@@ -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()
@@ -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:
"""插件状态检查器""" """插件状态检查器"""
+100
View File
@@ -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
+116
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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'
+23
View File
@@ -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)},
)
+15
View File
@@ -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
-52
View File
@@ -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()
-168
View File
@@ -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()
+30
View File
@@ -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