mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58ca7f156 | ||
|
|
c08f64ae75 | ||
|
|
48352563ef | ||
|
|
f87d8f43c8 | ||
|
|
568e3ca66b | ||
|
|
a9acc31a78 | ||
|
|
2866fc45b1 | ||
|
|
dcb68698e5 | ||
|
|
06f9751c84 | ||
|
|
23975442d8 | ||
|
|
577a13b271 | ||
|
|
05bb98dada | ||
|
|
4abe09dbc3 | ||
|
|
2bedeef26c | ||
|
|
8efcc394cc |
@@ -11,7 +11,7 @@
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"description": "Plugin metadata",
|
||||
"required": ["summary", "version", "description", "author"],
|
||||
"required": ["summary", "version", "description", "author", "tags", "database"],
|
||||
"additionalProperties": false,
|
||||
"x-tombi-table-keys-order": "schema",
|
||||
"properties": {
|
||||
@@ -44,7 +44,8 @@
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Plugin tags for categorization (will be required in next major version)",
|
||||
"minItems": 1,
|
||||
"description": "Plugin tags for categorization",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["ai", "mcp", "agent", "auth", "storage", "notification", "task", "payment", "other"]
|
||||
@@ -53,7 +54,8 @@
|
||||
},
|
||||
"database": {
|
||||
"type": "array",
|
||||
"description": "Supported databases (will be required in next major version)",
|
||||
"minItems": 1,
|
||||
"description": "Supported databases",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql"]
|
||||
|
||||
+875
-838
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -14,4 +14,4 @@ for cls in get_all_models():
|
||||
globals()[class_name] = cls
|
||||
|
||||
|
||||
__version__ = '1.13.1'
|
||||
__version__ = '1.13.2'
|
||||
|
||||
@@ -47,8 +47,8 @@ async def get_codes(db: CurrentSession, request: Request) -> ResponseSchemaModel
|
||||
|
||||
|
||||
@router.post('/refresh', summary='刷新 token')
|
||||
async def refresh_token(db: CurrentSession, request: Request) -> ResponseSchemaModel[GetNewToken]:
|
||||
data = await auth_service.refresh_token(db=db, request=request)
|
||||
async def refresh_token(db: CurrentSession, request: Request, response: Response) -> ResponseSchemaModel[GetNewToken]:
|
||||
data = await auth_service.refresh_token(db=db, request=request, response=response)
|
||||
return response_base.success(data=data)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ async def get_sessions(
|
||||
for key in token_keys:
|
||||
token = await redis_client.get(key)
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.id
|
||||
user_id = token_payload.user_id
|
||||
session_uuid = token_payload.session_uuid
|
||||
token_detail = GetTokenDetail(
|
||||
id=user_id,
|
||||
|
||||
@@ -25,7 +25,8 @@ from backend.app.admin.schema.user import (
|
||||
UpdateUserParam,
|
||||
)
|
||||
from backend.app.admin.utils.password_security import get_hash_password
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
from backend.common.exception import errors
|
||||
from backend.plugin.core import check_plugin_installed
|
||||
from backend.utils.serializers import select_join_serialize
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
@@ -298,17 +299,17 @@ class CRUDUser(CRUDPlus[User]):
|
||||
:param user_id: 用户 ID
|
||||
:return:
|
||||
"""
|
||||
if check_plugin_installed('oauth2'):
|
||||
try:
|
||||
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
|
||||
|
||||
await user_social_dao.delete_by_user_id(db, user_id)
|
||||
except ImportError:
|
||||
raise errors.ServerError(msg='OAuth2 插件用法导入失败,请联系系统管理员')
|
||||
|
||||
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
|
||||
await db.execute(user_role_stmt)
|
||||
|
||||
try:
|
||||
user_social = import_module_cached('backend.plugin.oauth2.crud.crud_user_social')
|
||||
user_social_dao = user_social.user_social_dao
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
else:
|
||||
await user_social_dao.delete_by_user_id(db, user_id)
|
||||
|
||||
return await self.delete_model(db, user_id)
|
||||
|
||||
async def get_join(
|
||||
|
||||
@@ -16,14 +16,14 @@ class OperaLog(DataClassBase):
|
||||
id: Mapped[id_key] = mapped_column(init=False)
|
||||
trace_id: Mapped[str] = mapped_column(sa.String(32), comment='请求跟踪 ID')
|
||||
username: Mapped[str | None] = mapped_column(sa.String(64), comment='用户名')
|
||||
method: Mapped[str] = mapped_column(sa.String(32), comment='请求类型')
|
||||
method: Mapped[str] = mapped_column(sa.String(32), comment='请求方法')
|
||||
title: Mapped[str] = mapped_column(sa.String(256), comment='操作模块')
|
||||
path: Mapped[str] = mapped_column(sa.String(512), comment='请求路径')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP地址')
|
||||
ip: Mapped[str] = mapped_column(sa.String(64), comment='IP 地址')
|
||||
country: 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='城市')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='请求头')
|
||||
user_agent: Mapped[str | None] = mapped_column(sa.String(512), comment='用户代理')
|
||||
os: 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='设备')
|
||||
|
||||
@@ -142,7 +142,7 @@ class AuthService:
|
||||
raise errors.NotFoundError(msg=e.msg)
|
||||
except (errors.RequestError, errors.CustomError) as e:
|
||||
if not user:
|
||||
log.error('登陆错误: 用户密码有误')
|
||||
log.error(f'登陆错误: {e.msg}')
|
||||
task = BackgroundTask(
|
||||
login_log_service.create,
|
||||
user_uuid=user.uuid if user else uuid4_str(),
|
||||
@@ -187,37 +187,42 @@ class AuthService:
|
||||
menus = await menu_dao.get_all(db, None, None)
|
||||
for menu in menus:
|
||||
if menu.perms:
|
||||
codes.add(*menu.perms.split(','))
|
||||
codes.update(menu.perms.split(','))
|
||||
else:
|
||||
roles = request.user.roles
|
||||
if roles:
|
||||
for role in roles:
|
||||
for menu in role.menus:
|
||||
if menu.perms:
|
||||
codes.add(*menu.perms.split(','))
|
||||
codes.update(menu.perms.split(','))
|
||||
|
||||
return list(codes)
|
||||
|
||||
@staticmethod
|
||||
async def refresh_token(*, db: AsyncSession, request: Request) -> GetNewToken:
|
||||
async def refresh_token(*, db: AsyncSession, request: Request, response: Response) -> GetNewToken:
|
||||
"""
|
||||
刷新令牌
|
||||
|
||||
:param db: 数据库会话
|
||||
:param request: FastAPI 请求对象
|
||||
:param response: FastAPI 响应对象
|
||||
:return:
|
||||
"""
|
||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
if not refresh_token:
|
||||
raise errors.RequestError(msg='Refresh Token 已过期,请重新登录')
|
||||
token_payload = jwt_decode(refresh_token)
|
||||
|
||||
user = await user_dao.get(db, token_payload.id)
|
||||
token_payload = jwt_decode(refresh_token)
|
||||
user = await user_dao.get(db, token_payload.user_id)
|
||||
if not user:
|
||||
raise errors.NotFoundError(msg='用户不存在')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
|
||||
if not user.is_multi_login and await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*'):
|
||||
if not user.is_multi_login and [
|
||||
key
|
||||
for key in await redis_client.get_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user.id}:*')
|
||||
if not key.endswith(f':{token_payload.session_uuid}')
|
||||
]:
|
||||
raise errors.ForbiddenError(msg='此用户已在异地登录,请重新登录并及时修改密码')
|
||||
new_token = await create_new_token(
|
||||
refresh_token,
|
||||
@@ -233,6 +238,13 @@ class AuthService:
|
||||
browser=ctx.browser,
|
||||
device_type=ctx.device,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=settings.COOKIE_REFRESH_TOKEN_KEY,
|
||||
value=new_token.new_refresh_token,
|
||||
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
expires=timezone.to_utc(new_token.new_refresh_token_expire_time),
|
||||
httponly=True,
|
||||
)
|
||||
data = GetNewToken(
|
||||
access_token=new_token.new_access_token,
|
||||
access_token_expire_time=new_token.new_access_token_expire_time,
|
||||
@@ -252,7 +264,7 @@ class AuthService:
|
||||
try:
|
||||
token = get_token(request)
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.id
|
||||
user_id = token_payload.user_id
|
||||
session_uuid = token_payload.session_uuid
|
||||
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
|
||||
except errors.TokenError:
|
||||
|
||||
@@ -25,9 +25,19 @@ class PluginService:
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
"""获取所有插件"""
|
||||
|
||||
keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*')]
|
||||
changed_key = f'{settings.PLUGIN_REDIS_PREFIX}:changed'
|
||||
keys = [key async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:*') if key != changed_key]
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
result = [json.loads(info) for info in await redis_client.mget(*keys)]
|
||||
result = []
|
||||
for info in await redis_client.mget(*keys):
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
plugin_info = json.loads(info)
|
||||
if isinstance(plugin_info, dict):
|
||||
result.append(plugin_info)
|
||||
|
||||
return result
|
||||
|
||||
@@ -97,6 +107,7 @@ class PluginService:
|
||||
)
|
||||
plugin_info['plugin']['enable'] = new_status
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}', json.dumps(plugin_info, ensure_ascii=False))
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
@staticmethod
|
||||
async def build(*, plugin: str) -> io.BytesIO:
|
||||
|
||||
+30
-28
@@ -43,7 +43,7 @@ from backend.database.db import (
|
||||
create_database_url,
|
||||
)
|
||||
from backend.database.redis import RedisCli, redis_client
|
||||
from backend.plugin.core import get_plugin_destroy_sql, get_plugin_sql, get_plugins
|
||||
from backend.plugin.core import build_sql_filename, get_plugin_destroy_sql, get_plugin_sql, get_plugins
|
||||
from backend.plugin.installer import install_git_plugin, install_zip_plugin, zip_plugin
|
||||
from backend.plugin.installer import remove_plugin as _remove_plugin
|
||||
from backend.plugin.requirements import uninstall_requirements_async
|
||||
@@ -346,7 +346,7 @@ def run_celery_flower(port: int, basic_auth: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def install_plugin(
|
||||
async def install_plugin( # noqa: C901
|
||||
path: str,
|
||||
repo_url: str,
|
||||
no_sql: bool, # noqa: FBT001
|
||||
@@ -373,6 +373,16 @@ async def install_plugin(
|
||||
|
||||
console.tip(f'插件 {plugin_name} 安装成功')
|
||||
|
||||
console.note(f'正在同步插件 {plugin_name} 数据库表...')
|
||||
try:
|
||||
import_module_cached(f'backend.plugin.{plugin_name}.model')
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
else:
|
||||
async with async_db_session.begin() as db:
|
||||
conn = await db.connection()
|
||||
await conn.run_sync(MappedBase.metadata.create_all)
|
||||
|
||||
if not no_sql:
|
||||
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
|
||||
if sql_file:
|
||||
@@ -445,23 +455,21 @@ async def remove_plugin(plugin: str | None, *, no_sql: bool = False) -> None: #
|
||||
|
||||
async def get_sql_scripts() -> list[str]:
|
||||
"""获取所有待执行的 SQL 脚本路径列表"""
|
||||
sql_scripts = []
|
||||
sql_scripts: list[str] = []
|
||||
db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR
|
||||
main_sql_file = (
|
||||
db_script_dir / 'init_test_data.sql'
|
||||
if PrimaryKeyType.autoincrement == settings.DATABASE_PK_MODE
|
||||
else db_script_dir / 'init_snowflake_test_data.sql'
|
||||
main_sql_file = db_script_dir / build_sql_filename(
|
||||
'init',
|
||||
settings.DATABASE_PK_MODE,
|
||||
suffix='test_data',
|
||||
)
|
||||
|
||||
main_sql_path = anyio.Path(main_sql_file)
|
||||
if await main_sql_path.exists():
|
||||
if await anyio.Path(main_sql_file).exists():
|
||||
sql_scripts.append(str(main_sql_file))
|
||||
|
||||
plugins = get_plugins()
|
||||
for plugin in plugins:
|
||||
for plugin in get_plugins():
|
||||
plugin_sql = await get_plugin_sql(plugin, settings.DATABASE_TYPE, settings.DATABASE_PK_MODE)
|
||||
if plugin_sql:
|
||||
sql_scripts.append(str(plugin_sql))
|
||||
sql_scripts.append(plugin_sql)
|
||||
|
||||
return sql_scripts
|
||||
|
||||
@@ -500,8 +508,11 @@ async def import_table(
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
|
||||
|
||||
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
try:
|
||||
from backend.plugin.code_generator.schema.gen import ImportParam
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
|
||||
|
||||
try:
|
||||
obj = ImportParam(app=app, table_schema=table_schema, table_name=table_name)
|
||||
@@ -518,8 +529,11 @@ async def generate(*, preview: bool = False) -> None:
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise cappa.Exit('代码生成仅在开发环境可用', code=1)
|
||||
|
||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
try:
|
||||
from backend.plugin.code_generator.service.business_service import gen_business_service
|
||||
from backend.plugin.code_generator.service.gen_service import gen_service
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件用法导入失败,请联系系统管理员', code=1)
|
||||
|
||||
try:
|
||||
ids = []
|
||||
@@ -753,12 +767,6 @@ class Import:
|
||||
cappa.Arg(short='tn', help='数据库表名'),
|
||||
]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
import_module_cached('backend.plugin.code_generator')
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await import_table(self.app, self.table_schema, self.table_name)
|
||||
|
||||
@@ -772,12 +780,6 @@ class CodeGenerator:
|
||||
]
|
||||
subcmd: cappa.Subcommands[Import | None] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
import_module_cached('backend.plugin.code_generator')
|
||||
except ImportError:
|
||||
raise cappa.Exit('代码生成插件不存在,请先安装此插件')
|
||||
|
||||
async def __call__(self) -> None:
|
||||
await generate(preview=self.preview)
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class NewToken:
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenPayload:
|
||||
id: int
|
||||
user_id: int
|
||||
session_uuid: str
|
||||
expire_time: datetime
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class OperaLogCipherType(IntEnum):
|
||||
aes = 0
|
||||
md5 = 1
|
||||
itsdangerous = 2
|
||||
plan = 3
|
||||
plain = 3
|
||||
|
||||
|
||||
class StatusType(IntEnum):
|
||||
|
||||
@@ -54,7 +54,7 @@ class I18n:
|
||||
case 'json':
|
||||
self.locales[lang] = json.loads(f.read())
|
||||
case 'yaml' | 'yml':
|
||||
self.locales[lang] = yaml.full_load(f.read())
|
||||
self.locales[lang] = yaml.safe_load(f.read())
|
||||
|
||||
def t(self, key: str, default: Any | None = None, **kwargs) -> str:
|
||||
"""
|
||||
|
||||
+27
-12
@@ -4,6 +4,8 @@ import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from backend.core.conf import settings
|
||||
@@ -35,23 +37,36 @@ class InterceptHandler(logging.Handler):
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def default_formatter(record: logging.LogRecord) -> str:
|
||||
"""默认日志格式化程序"""
|
||||
def default_formatter(record: dict) -> str:
|
||||
"""
|
||||
默认日志格式化程序
|
||||
|
||||
:param record: Loguru Record 对象
|
||||
:return:
|
||||
"""
|
||||
# 重写 sqlalchemy echo 输出
|
||||
# https://github.com/sqlalchemy/sqlalchemy/discussions/12791
|
||||
record_name = record['name'] or ''
|
||||
if record_name.startswith('sqlalchemy'):
|
||||
record['message'] = re.sub(r'\s+', ' ', record['message']).strip()
|
||||
|
||||
return settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
|
||||
base_format = settings.LOG_FORMAT if settings.LOG_FORMAT.endswith('\n') else f'{settings.LOG_FORMAT}\n'
|
||||
if record.get('exception') is not None:
|
||||
base_format += '{exception}\n'
|
||||
|
||||
return base_format
|
||||
|
||||
|
||||
def request_id_filter(record: logging.LogRecord) -> logging.LogRecord:
|
||||
"""请求 ID 过滤器"""
|
||||
def request_id_filter(record: dict) -> bool:
|
||||
"""
|
||||
请求 ID 过滤器
|
||||
|
||||
:param record: Loguru Record 对象
|
||||
:return:
|
||||
"""
|
||||
rid = get_request_trace_id()
|
||||
record['request_id'] = rid[: settings.TRACE_ID_LOG_LENGTH]
|
||||
return record
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
@@ -84,14 +99,14 @@ def setup_logging() -> None:
|
||||
|
||||
# 配置 loguru 处理器
|
||||
logger.configure(
|
||||
handlers=[
|
||||
handlers=[ # type: ignore[arg-type]
|
||||
{
|
||||
'sink': sys.stdout,
|
||||
'level': settings.LOG_STD_LEVEL,
|
||||
'format': default_formatter,
|
||||
'filter': lambda record: request_id_filter(record),
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -109,12 +124,12 @@ def set_custom_logfile() -> None:
|
||||
filename = filepath.split(os.sep)[-1]
|
||||
original_filename = filename.split('.')[0]
|
||||
if '-' in original_filename:
|
||||
return LOG_DIR / f'{original_filename}.log'
|
||||
return LOG_DIR / f'{original_filename}_{timezone.now().strftime("%Y-%m-%d")}.log'
|
||||
return str(LOG_DIR / f'{original_filename}.log')
|
||||
return str(LOG_DIR / f'{original_filename}_{timezone.now().strftime("%Y-%m-%d")}.log')
|
||||
|
||||
# 日志文件通用配置
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
|
||||
log_config = {
|
||||
log_config: dict[str, Any] = {
|
||||
'format': default_formatter,
|
||||
'enqueue': True,
|
||||
'rotation': '00:00',
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.admin.model import User
|
||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||
from backend.common.context import ctx
|
||||
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
@@ -58,7 +59,7 @@ def jwt_decode(token: str) -> TokenPayload:
|
||||
except (JWTError, Exception):
|
||||
raise errors.TokenError(msg='Token 无效')
|
||||
return TokenPayload(
|
||||
id=int(user_id),
|
||||
user_id=int(user_id),
|
||||
session_uuid=session_uuid,
|
||||
expire_time=timezone.from_datetime(timezone.to_utc(expire)),
|
||||
)
|
||||
@@ -205,7 +206,7 @@ async def get_current_user(db: AsyncSession, pk: int) -> User:
|
||||
raise errors.TokenError(msg='Token 无效')
|
||||
if not user.status:
|
||||
raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员')
|
||||
if user.dept_id:
|
||||
if user.dept and user.dept_id:
|
||||
if not user.dept.status:
|
||||
raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员')
|
||||
if user.dept.del_flag:
|
||||
@@ -241,6 +242,25 @@ async def get_jwt_user(user_id: int) -> GetUserInfoWithRelationDetail:
|
||||
return user
|
||||
|
||||
|
||||
async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
||||
"""
|
||||
JWT 认证
|
||||
|
||||
:param token: JWT token
|
||||
:return:
|
||||
"""
|
||||
token_payload = jwt_decode(token)
|
||||
ctx.user_id = token_payload.user_id
|
||||
redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{ctx.user_id}:{token_payload.session_uuid}')
|
||||
if not redis_token:
|
||||
raise errors.TokenError(msg='Token 已过期')
|
||||
|
||||
if token != redis_token:
|
||||
raise errors.TokenError(msg='Token 已失效')
|
||||
|
||||
return await get_jwt_user(ctx.user_id)
|
||||
|
||||
|
||||
def superuser_verify(request: Request, _token: str = DependsJwtAuth) -> bool:
|
||||
"""
|
||||
验证当前用户超级管理员权限
|
||||
@@ -255,24 +275,5 @@ def superuser_verify(request: Request, _token: str = DependsJwtAuth) -> bool:
|
||||
return superuser
|
||||
|
||||
|
||||
async def jwt_authentication(token: str) -> GetUserInfoWithRelationDetail:
|
||||
"""
|
||||
JWT 认证
|
||||
|
||||
:param token: JWT token
|
||||
:return:
|
||||
"""
|
||||
token_payload = jwt_decode(token)
|
||||
user_id = token_payload.id
|
||||
redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
|
||||
if not redis_token:
|
||||
raise errors.TokenError(msg='Token 已过期')
|
||||
|
||||
if token != redis_token:
|
||||
raise errors.TokenError(msg='Token 已失效')
|
||||
|
||||
return await get_jwt_user(user_id)
|
||||
|
||||
|
||||
# 超级管理员鉴权依赖注入
|
||||
DependsSuperUser = Depends(superuser_verify)
|
||||
|
||||
@@ -3,10 +3,8 @@ from fastapi import Depends, Request
|
||||
from backend.common.context import ctx
|
||||
from backend.common.enums import MethodType, StatusType
|
||||
from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import DependsJwtAuth
|
||||
from backend.core.conf import settings
|
||||
from backend.utils.dynamic_import import import_module_cached
|
||||
|
||||
|
||||
async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: # noqa: C901
|
||||
@@ -36,16 +34,17 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
|
||||
|
||||
# 检测用户角色
|
||||
user_roles = request.user.roles
|
||||
if not user_roles or all(status == 0 for status in user_roles):
|
||||
raise errors.AuthorizationError(msg='用户未分配角色,请联系系统管理员')
|
||||
enabled_roles = [role for role in user_roles if role.status == StatusType.enable]
|
||||
if not enabled_roles:
|
||||
raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员')
|
||||
|
||||
# 检测用户所属角色菜单
|
||||
if not any(len(role.menus) > 0 for role in user_roles):
|
||||
if not any(len(role.menus) > 0 for role in enabled_roles):
|
||||
raise errors.AuthorizationError(msg='用户未分配菜单,请联系系统管理员')
|
||||
|
||||
# 检测后台管理操作权限
|
||||
method = request.method
|
||||
if (method != MethodType.GET or method != MethodType.OPTIONS) and not request.user.is_staff:
|
||||
if method not in {MethodType.GET, MethodType.OPTIONS} and not request.user.is_staff:
|
||||
raise errors.AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员')
|
||||
|
||||
# RBAC 鉴权
|
||||
@@ -62,7 +61,7 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
|
||||
|
||||
# 菜单去重
|
||||
unique_menus = {}
|
||||
for role in user_roles:
|
||||
for role in enabled_roles:
|
||||
for menu in role.menus:
|
||||
unique_menus[menu.id] = menu
|
||||
|
||||
@@ -74,12 +73,11 @@ async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None:
|
||||
if path_auth_perm not in allow_perms:
|
||||
raise errors.AuthorizationError
|
||||
else:
|
||||
# casbin 模式
|
||||
try:
|
||||
casbin_rbac = import_module_cached('backend.plugin.casbin_rbac.rbac')
|
||||
casbin_verify = casbin_rbac.casbin_verify
|
||||
except (ImportError, AttributeError) as e:
|
||||
log.error(f'正在通过 casbin 执行 RBAC 权限校验,但此插件不存在: {e}')
|
||||
raise errors.ServerError(msg='权限校验失败,请联系系统管理员')
|
||||
from backend.plugin.casbin_rbac.rbac import casbin_verify
|
||||
except ImportError:
|
||||
raise errors.ServerError(msg='Casbin RBAC 插件用法导入失败,请联系系统管理员')
|
||||
|
||||
await casbin_verify(request)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
import socketio
|
||||
|
||||
from starlette_context import request_cycle_context
|
||||
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import jwt_authentication
|
||||
from backend.core.conf import settings
|
||||
@@ -38,7 +41,8 @@ async def connect(sid, environ, auth) -> bool:
|
||||
return True
|
||||
|
||||
try:
|
||||
await jwt_authentication(token)
|
||||
with request_cycle_context({settings.TRACE_ID_REQUEST_HEADER_KEY: uuid.uuid4().hex}):
|
||||
await jwt_authentication(token)
|
||||
except Exception as e:
|
||||
log.info(f'WebSocket 连接失败:{e!s}')
|
||||
return False
|
||||
|
||||
@@ -247,6 +247,7 @@ class Settings(BaseSettings):
|
||||
OPERA_LOG_QUEUE_TIMEOUT: int = 60 # 1 分钟
|
||||
|
||||
# Plugin 配置
|
||||
PLUGIN_REQUIRED: list[str] = ['dict']
|
||||
PLUGIN_PIP_CHINA: bool = True
|
||||
PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/'
|
||||
PLUGIN_PIP_MAX_RETRY: int = 3
|
||||
|
||||
+6
-1
@@ -2,12 +2,17 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
||||
from rich.text import Text
|
||||
|
||||
from backend.core.registrar import register_app
|
||||
from backend.plugin.core import get_plugins
|
||||
from backend.plugin.core import check_required_plugins, get_plugins
|
||||
from backend.plugin.requirements import install_requirements
|
||||
from backend.utils.console import console
|
||||
from backend.utils.timezone import timezone
|
||||
|
||||
_log_prefix = f'{timezone.to_str(timezone.now(), "%Y-%m-%d %H:%M:%S.%M0")} | {"INFO": <8} | - | '
|
||||
|
||||
console.print(Text(f'{_log_prefix}检查必需插件...', style='bold cyan'))
|
||||
|
||||
check_required_plugins()
|
||||
|
||||
console.print(Text(f'{_log_prefix}检测插件依赖...', style='bold cyan'))
|
||||
|
||||
_plugins = get_plugins()
|
||||
|
||||
@@ -25,18 +25,18 @@ class AccessMiddleware(BaseHTTPMiddleware):
|
||||
:param call_next: 下一个中间件或路由处理函数
|
||||
:return:
|
||||
"""
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
if method != 'OPTIONS':
|
||||
log.debug(f'--> 请求开始[{path if not request.url.query else request.url.path + "/" + request.url.query}]')
|
||||
|
||||
perf_time = time.perf_counter()
|
||||
ctx.perf_time = perf_time
|
||||
|
||||
start_time = timezone.now()
|
||||
ctx.start_time = start_time
|
||||
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
if method != 'OPTIONS':
|
||||
log.debug(f'--> 请求开始[{path if not request.url.query else request.url.path + "?" + request.url.query}]')
|
||||
|
||||
if path.startswith(settings.FASTAPI_API_V1_PATH):
|
||||
PROMETHEUS_REQUEST_IN_PROGRESS_GAUGE.labels(app_name=PROMETHEUS_APP_NAME, method=method, path=path).inc()
|
||||
PROMETHEUS_REQUEST_COUNTER.labels(app_name=PROMETHEUS_APP_NAME, method=method, path=path).inc()
|
||||
|
||||
@@ -7,7 +7,6 @@ from starlette.authentication import AuthenticationError as StarletteAuthenticat
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
|
||||
from backend.common.context import ctx
|
||||
from backend.common.exception.errors import TokenError
|
||||
from backend.common.log import log
|
||||
from backend.common.security.jwt import jwt_authentication
|
||||
@@ -26,7 +25,7 @@ class AuthenticationError(StarletteAuthenticationError):
|
||||
headers: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化认证错误
|
||||
初始化认证错误类
|
||||
|
||||
:param code: 错误码
|
||||
:param msg: 错误信息
|
||||
@@ -96,9 +95,6 @@ class JwtAuthMiddleware(AuthenticationBackend):
|
||||
log.exception(f'JWT 授权异常:{e}')
|
||||
raise AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
|
||||
|
||||
# 设置用户 ID 到上下文
|
||||
ctx.user_id = user.id
|
||||
|
||||
# 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性
|
||||
# 标准返回模式请查看:https://www.starlette.io/authentication/
|
||||
return AuthCredentials(['authenticated']), user
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Sequence
|
||||
from jinja2 import Environment, FileSystemLoader, Template, select_autoescape
|
||||
from pydantic.alias_generators import to_pascal
|
||||
|
||||
from backend.common.enums import PrimaryKeyType
|
||||
from backend.core.conf import settings
|
||||
from backend.plugin.code_generator.model import GenBusiness, GenColumn
|
||||
from backend.plugin.code_generator.path_conf import JINJA2_TEMPLATE_DIR
|
||||
@@ -109,7 +110,7 @@ class GenTemplate:
|
||||
'now': timezone.now(),
|
||||
}
|
||||
|
||||
if settings.DATABASE_PK_MODE == 'snowflake':
|
||||
if PrimaryKeyType.snowflake == settings.DATABASE_PK_MODE:
|
||||
vars_dict['parent_menu_id'] = snowflake.generate()
|
||||
vars_dict['button_ids'] = [snowflake.generate() for _ in range(4)]
|
||||
|
||||
|
||||
+52
-40
@@ -22,6 +22,27 @@ from backend.utils.async_helper import run_await
|
||||
from backend.utils.dynamic_import import get_model_objects, import_module_cached
|
||||
|
||||
|
||||
def check_plugin_installed(plugin_name: str) -> bool:
|
||||
"""
|
||||
检查插件是否已安装
|
||||
|
||||
:param plugin_name: 插件名称
|
||||
:return:
|
||||
"""
|
||||
return (PLUGIN_DIR / plugin_name / '__init__.py').exists()
|
||||
|
||||
|
||||
def check_required_plugins() -> None:
|
||||
"""检查必需插件"""
|
||||
required_plugins = list(settings.PLUGIN_REQUIRED)
|
||||
if not settings.RBAC_ROLE_MENU_MODE and 'casbin_rbac' not in required_plugins:
|
||||
required_plugins.append('casbin_rbac')
|
||||
|
||||
missing_plugins = [name for name in required_plugins if not check_plugin_installed(name)]
|
||||
if missing_plugins:
|
||||
raise PluginInjectError(f'当前系统缺少以下插件: {", ".join(missing_plugins)},请先安装对应插件')
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_plugins() -> tuple[str, ...]:
|
||||
"""获取插件列表"""
|
||||
@@ -53,6 +74,20 @@ def get_plugin_models() -> list[object]:
|
||||
return objs
|
||||
|
||||
|
||||
def build_sql_filename(
|
||||
prefix: str,
|
||||
pk_type: PrimaryKeyType,
|
||||
*,
|
||||
suffix: str | None = None,
|
||||
) -> str:
|
||||
parts = [prefix]
|
||||
if pk_type == PrimaryKeyType.snowflake:
|
||||
parts.append('snowflake')
|
||||
if suffix:
|
||||
parts.append(suffix)
|
||||
return f'{"_".join(parts)}.sql'
|
||||
|
||||
|
||||
async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
|
||||
"""
|
||||
获取插件 SQL 脚本
|
||||
@@ -62,24 +97,10 @@ async def get_plugin_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKey
|
||||
:param pk_type: 主键类型
|
||||
:return:
|
||||
"""
|
||||
if db_type == DataBaseType.mysql:
|
||||
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
|
||||
sql_file = (
|
||||
mysql_dir / 'init.sql' if pk_type == PrimaryKeyType.autoincrement else mysql_dir / 'init_snowflake.sql'
|
||||
)
|
||||
else:
|
||||
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
|
||||
sql_file = (
|
||||
postgresql_dir / 'init.sql'
|
||||
if pk_type == PrimaryKeyType.autoincrement
|
||||
else postgresql_dir / 'init_snowflake.sql'
|
||||
)
|
||||
|
||||
path = anyio.Path(sql_file)
|
||||
if not await path.exists():
|
||||
return None
|
||||
|
||||
return sql_file
|
||||
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
|
||||
default_filename = build_sql_filename('init', pk_type)
|
||||
default_sql_file = sql_dir / default_filename
|
||||
return str(default_sql_file) if await anyio.Path(default_sql_file).exists() else None
|
||||
|
||||
|
||||
async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: PrimaryKeyType) -> str | None:
|
||||
@@ -91,26 +112,9 @@ async def get_plugin_destroy_sql(plugin: str, db_type: DataBaseType, pk_type: Pr
|
||||
:param pk_type: 主键类型
|
||||
:return:
|
||||
"""
|
||||
if db_type == DataBaseType.mysql:
|
||||
mysql_dir = PLUGIN_DIR / plugin / 'sql' / 'mysql'
|
||||
sql_file = (
|
||||
mysql_dir / 'destroy.sql'
|
||||
if pk_type == PrimaryKeyType.autoincrement
|
||||
else mysql_dir / 'destroy_snowflake.sql'
|
||||
)
|
||||
else:
|
||||
postgresql_dir = PLUGIN_DIR / plugin / 'sql' / 'postgresql'
|
||||
sql_file = (
|
||||
postgresql_dir / 'destroy.sql'
|
||||
if pk_type == PrimaryKeyType.autoincrement
|
||||
else postgresql_dir / 'destroy_snowflake.sql'
|
||||
)
|
||||
|
||||
path = anyio.Path(sql_file)
|
||||
if not await path.exists():
|
||||
return None
|
||||
|
||||
return sql_file
|
||||
sql_dir = PLUGIN_DIR / plugin / 'sql' / ('mysql' if db_type == DataBaseType.mysql else 'postgresql')
|
||||
sql_file = sql_dir / build_sql_filename('destroy', pk_type)
|
||||
return str(sql_file) if await anyio.Path(sql_file).exists() else None
|
||||
|
||||
|
||||
def load_plugin_config(plugin: str) -> dict[str, Any]:
|
||||
@@ -158,7 +162,10 @@ def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
data['plugin']['name'] = plugin
|
||||
plugin_cache_info = run_await(current_redis_client.get)(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}')
|
||||
if plugin_cache_info:
|
||||
data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable']
|
||||
try:
|
||||
data['plugin']['enable'] = json.loads(plugin_cache_info)['plugin']['enable']
|
||||
except Exception:
|
||||
data['plugin']['enable'] = str(StatusType.enable.value)
|
||||
else:
|
||||
data['plugin']['enable'] = str(StatusType.enable.value)
|
||||
|
||||
@@ -306,5 +313,10 @@ class PluginStatusChecker:
|
||||
log.error('插件状态未初始化或丢失,需重启服务自动修复')
|
||||
raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员')
|
||||
|
||||
if not int(json.loads(plugin_info)['plugin']['enable']):
|
||||
try:
|
||||
is_enabled = int(json.loads(plugin_info)['plugin']['enable'])
|
||||
except Exception:
|
||||
is_enabled = 0
|
||||
|
||||
if not is_enabled:
|
||||
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')
|
||||
|
||||
+29
-11
@@ -51,7 +51,7 @@ async def _append_env_example(plugin_path: anyio.Path) -> None:
|
||||
await f.write(new_content)
|
||||
|
||||
|
||||
async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||
async def install_zip_plugin(file: UploadFile | str) -> str: # noqa: C901
|
||||
"""
|
||||
安装 ZIP 插件
|
||||
|
||||
@@ -71,9 +71,11 @@ async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||
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='插件压缩包内容非法')
|
||||
plugin_dir_name = plugin_namelist[0].split('/', 1)[0].strip()
|
||||
if not plugin_dir_name:
|
||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||
if (
|
||||
len(plugin_namelist) <= 3
|
||||
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
|
||||
@@ -82,29 +84,45 @@ async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
|
||||
|
||||
# 插件是否可安装
|
||||
plugin_name = re.match(
|
||||
plugin_name_match = 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()
|
||||
)
|
||||
if not plugin_name_match:
|
||||
raise errors.RequestError(msg='插件压缩包文件名非法')
|
||||
plugin_name = plugin_name_match.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 = []
|
||||
prefix = f'{plugin_dir_name}/'
|
||||
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)
|
||||
if member.filename in {plugin_dir_name, prefix}:
|
||||
continue
|
||||
if not member.filename.startswith(prefix):
|
||||
continue
|
||||
|
||||
relative_filename = member.filename.removeprefix(prefix)
|
||||
if not relative_filename:
|
||||
if member.is_dir():
|
||||
continue
|
||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||
|
||||
member.filename = relative_filename
|
||||
members.append(member)
|
||||
|
||||
if not members:
|
||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||
|
||||
await full_plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
await run_in_threadpool(zf.extractall, full_plugin_path, members)
|
||||
|
||||
await _append_env_example(full_plugin_path)
|
||||
await install_requirements_async(plugin_dir_name)
|
||||
await install_requirements_async(plugin_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true')
|
||||
|
||||
return plugin_name
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
drop table if exists sys_user_social;
|
||||
@@ -0,0 +1 @@
|
||||
drop table if exists sys_user_social;
|
||||
@@ -0,0 +1 @@
|
||||
select 1;
|
||||
@@ -0,0 +1 @@
|
||||
select 1;
|
||||
@@ -0,0 +1 @@
|
||||
drop table if exists sys_user_social;
|
||||
@@ -0,0 +1 @@
|
||||
drop table if exists sys_user_social;
|
||||
@@ -0,0 +1 @@
|
||||
select 1;
|
||||
@@ -0,0 +1 @@
|
||||
select 1;
|
||||
+34
-18
@@ -1,10 +1,10 @@
|
||||
import warnings
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.common.enums import PluginLevelType
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.plugin.errors import PluginConfigError
|
||||
from backend.utils.pattern_validate import match_string
|
||||
|
||||
@@ -23,8 +23,8 @@ class PluginInfoSchema(BaseModel):
|
||||
version: str = Field(..., description='版本号')
|
||||
description: str = Field(..., min_length=1, max_length=500, description='描述')
|
||||
author: str = Field(..., min_length=1, max_length=50, description='作者')
|
||||
tags: list[str] = Field(default_factory=list, description='标签')
|
||||
database: list[str] = Field(default_factory=list, description='数据库支持')
|
||||
tags: list[str] = Field(..., min_length=1, description='标签')
|
||||
database: list[str] = Field(..., min_length=1, description='数据库支持')
|
||||
|
||||
@field_validator('version')
|
||||
@classmethod
|
||||
@@ -183,19 +183,35 @@ def validate_plugin_config(plugin_name: str, config: dict[str, Any]) -> PluginLe
|
||||
error_msg = '; '.join(error_details)
|
||||
raise PluginConfigError(f'插件 {plugin_name} 配置校验失败: {error_msg}') from e
|
||||
|
||||
# TODO 下个重大版本变更为必填
|
||||
plugin_info = config.get('plugin', {})
|
||||
if not plugin_info.get('tags'):
|
||||
warnings.warn(
|
||||
f"插件 '{plugin_name}' 未配置 'tags' 字段,该字段将在下个重大版本中必填,请及时联系插件作者同步更新",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if not plugin_info.get('database'):
|
||||
warnings.warn(
|
||||
f"插件 '{plugin_name}' 未配置 'database' 字段,该字段将在下个重大版本中必填,请及时联系插件作者同步更新",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
plugin_dir = Path(PLUGIN_DIR) / plugin_name
|
||||
model_dir = plugin_dir / 'model'
|
||||
if model_dir.is_dir():
|
||||
sql_dir = plugin_dir / 'sql'
|
||||
supported_db_types = []
|
||||
missing_details = []
|
||||
|
||||
for db_type in ('mysql', 'postgresql'):
|
||||
db_sql_dir = sql_dir / db_type
|
||||
required_sql_files = (
|
||||
db_sql_dir / 'init.sql',
|
||||
db_sql_dir / 'destroy.sql',
|
||||
db_sql_dir / 'init_snowflake.sql',
|
||||
db_sql_dir / 'destroy_snowflake.sql',
|
||||
)
|
||||
missing_files = [
|
||||
str(sql_file.relative_to(plugin_dir)) for sql_file in required_sql_files if not sql_file.is_file()
|
||||
]
|
||||
|
||||
if not missing_files:
|
||||
supported_db_types.append(db_type)
|
||||
continue
|
||||
|
||||
missing_details.append(f'{db_type}: {", ".join(missing_files)}')
|
||||
|
||||
if not supported_db_types:
|
||||
raise PluginConfigError(
|
||||
f'插件 {plugin_name} 必须至少提供一种数据库的初始化和销毁 SQL 脚本,'
|
||||
f'当前缺失: {"; ".join(missing_details)}'
|
||||
)
|
||||
|
||||
return plugin_level
|
||||
|
||||
+8
-9
@@ -1,10 +1,8 @@
|
||||
import os
|
||||
import granian
|
||||
|
||||
import uvicorn
|
||||
from backend.cli import CustomReloadFilter
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 为什么独立此启动文件:https://stackoverflow.com/questions/64003384
|
||||
|
||||
# DEBUG:
|
||||
# 如果你喜欢在 IDE 中进行 DEBUG,可在 IDE 中直接右键启动此文件
|
||||
# 如果你喜欢通过 print 方式进行调试,建议使用 fba cli 方式启动服务
|
||||
@@ -13,10 +11,11 @@ if __name__ == '__main__':
|
||||
# 如果你正在通过 python 命令启动此文件,请遵循以下事宜:
|
||||
# 1. 按照官方文档通过 uv 安装依赖
|
||||
# 2. 命令行空间位于 backend 目录下
|
||||
uvicorn.run(
|
||||
app='backend.main:app',
|
||||
host='127.0.0.1',
|
||||
granian.Granian(
|
||||
target='main:app',
|
||||
interface='asgi',
|
||||
address='127.0.0.1',
|
||||
port=8000,
|
||||
reload=True,
|
||||
reload_excludes=[os.path.abspath('../.venv')],
|
||||
)
|
||||
reload_filter=CustomReloadFilter,
|
||||
).serve()
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.core.conf import settings
|
||||
from backend.database.db import async_engine
|
||||
from backend.plugin.config.enums import ConfigType
|
||||
from backend.plugin.config.service.config_service import config_service
|
||||
from backend.plugin.core import check_plugin_installed
|
||||
from backend.utils.serializers import select_list_serialize
|
||||
|
||||
_sys_config_table_exists: bool | None = None
|
||||
_config_plugin_installed = check_plugin_installed('config')
|
||||
|
||||
|
||||
async def check_sys_config_table_exists() -> bool:
|
||||
"""检查 sys_config 表是否存在"""
|
||||
global _sys_config_table_exists
|
||||
if _sys_config_table_exists is None:
|
||||
async with async_engine.connect() as conn:
|
||||
_sys_config_table_exists = await conn.run_sync(lambda c: inspect(c).has_table('sys_config', schema=None))
|
||||
return _sys_config_table_exists
|
||||
if _config_plugin_installed:
|
||||
try:
|
||||
from backend.plugin.config.enums import ConfigType
|
||||
from backend.plugin.config.service.config_service import config_service
|
||||
except ImportError:
|
||||
raise ImportError('参数配置插件用法导入失败,请联系系统管理员')
|
||||
else:
|
||||
ConfigType = None
|
||||
config_service = None
|
||||
|
||||
|
||||
def _to_bool(value: str) -> bool:
|
||||
@@ -41,7 +42,7 @@ async def _load_config(
|
||||
:param status_key: 状态键
|
||||
:return:
|
||||
"""
|
||||
if not await check_sys_config_table_exists():
|
||||
if not _config_plugin_installed or config_service is None:
|
||||
return
|
||||
|
||||
dynamic_config = await config_service.get_all(db=db, type=config_type)
|
||||
@@ -65,6 +66,9 @@ async def load_user_security_config(db: AsyncSession) -> None:
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
if ConfigType is None:
|
||||
return
|
||||
|
||||
mapping = {
|
||||
'USER_LOCK_THRESHOLD': int,
|
||||
'USER_LOCK_SECONDS': int,
|
||||
@@ -85,6 +89,9 @@ async def load_login_config(db: AsyncSession) -> None:
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
if ConfigType is None:
|
||||
return
|
||||
|
||||
mapping = {
|
||||
'LOGIN_CAPTCHA_ENABLED': _to_bool,
|
||||
}
|
||||
@@ -98,6 +105,9 @@ async def load_email_config(db: AsyncSession) -> None:
|
||||
:param db: 数据库会话
|
||||
:return:
|
||||
"""
|
||||
if ConfigType is None:
|
||||
return
|
||||
|
||||
mapping = {
|
||||
'EMAIL_HOST': str,
|
||||
'EMAIL_PORT': int,
|
||||
|
||||
@@ -7,9 +7,6 @@ from typing import Any, TypeVar
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
@@ -24,22 +21,6 @@ def import_module_cached(module_path: str) -> Any:
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
def dynamic_import_data_model(module_path: str) -> type[T]:
|
||||
"""
|
||||
动态导入数据模型
|
||||
|
||||
:param module_path: 模块路径,格式为 'module_path.class_name'
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
module_path, class_name = module_path.rsplit('.', 1)
|
||||
module = import_module_cached(module_path)
|
||||
return getattr(module, class_name)
|
||||
except Exception as e:
|
||||
log.error(f'动态导入数据模型失败:{e}')
|
||||
raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员')
|
||||
|
||||
|
||||
def get_model_objects(module_path: str) -> list[object] | None:
|
||||
"""
|
||||
获取模型对象
|
||||
|
||||
@@ -26,6 +26,9 @@ def get_request_ip(request: Request) -> str:
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0]
|
||||
|
||||
if request.client is None:
|
||||
return '127.0.0.1'
|
||||
|
||||
# 忽略 pytest
|
||||
if request.client.host == 'testclient':
|
||||
return '127.0.0.1'
|
||||
|
||||
Reference in New Issue
Block a user