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