From 05bb98dada00977fd0a4e1570e9b2b1a1ae7dbb6 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:48:11 +0800 Subject: [PATCH 1/6] Update user id context setting location (#1110) * Update user id context setting location * Update jwt middleware * Fix lint --- backend/common/dataclasses.py | 2 +- backend/common/security/jwt.py | 43 ++++++++++++----------- backend/common/socketio/server.py | 6 +++- backend/middleware/jwt_auth_middleware.py | 6 +--- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/backend/common/dataclasses.py b/backend/common/dataclasses.py index 3bd1b559..b0dfdec2 100644 --- a/backend/common/dataclasses.py +++ b/backend/common/dataclasses.py @@ -56,7 +56,7 @@ class NewToken: @dataclasses.dataclass class TokenPayload: - id: int + user_id: int session_uuid: str expire_time: datetime diff --git a/backend/common/security/jwt.py b/backend/common/security/jwt.py index bc364afb..704d6304 100644 --- a/backend/common/security/jwt.py +++ b/backend/common/security/jwt.py @@ -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) diff --git a/backend/common/socketio/server.py b/backend/common/socketio/server.py index 6e237878..64d62968 100644 --- a/backend/common/socketio/server.py +++ b/backend/common/socketio/server.py @@ -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 diff --git a/backend/middleware/jwt_auth_middleware.py b/backend/middleware/jwt_auth_middleware.py index 4e90829b..8ed032e9 100644 --- a/backend/middleware/jwt_auth_middleware.py +++ b/backend/middleware/jwt_auth_middleware.py @@ -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 From 577a13b271b4a8cfb7df8999cca058bf62b8d2c5 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:48:42 +0800 Subject: [PATCH 2/6] Update refresh token geo-checksum response (#1111) --- backend/app/admin/api/v1/auth/auth.py | 4 ++-- backend/app/admin/service/auth_service.py | 24 +++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/app/admin/api/v1/auth/auth.py b/backend/app/admin/api/v1/auth/auth.py index e513c267..8ad88e49 100644 --- a/backend/app/admin/api/v1/auth/auth.py +++ b/backend/app/admin/api/v1/auth/auth.py @@ -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) diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py index 859b22d1..75a8971a 100644 --- a/backend/app/admin/service/auth_service.py +++ b/backend/app/admin/service/auth_service.py @@ -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(), @@ -199,25 +199,30 @@ class AuthService: 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: From 23975442d8e2344aa68fd1352c04d93c9c6434ae Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:49:33 +0800 Subject: [PATCH 3/6] Update plugin dynamic import check (#1112) * Update plugin dynamic import check * Update cli dynamic import --- backend/app/admin/crud/crud_user.py | 19 +++--- backend/cli.py | 58 +++++++++--------- backend/core/conf.py | 1 + backend/main.py | 7 ++- backend/plugin/core.py | 92 ++++++++++++++++------------- backend/utils/dynamic_config.py | 32 ++++++---- backend/utils/dynamic_import.py | 19 ------ 7 files changed, 120 insertions(+), 108 deletions(-) diff --git a/backend/app/admin/crud/crud_user.py b/backend/app/admin/crud/crud_user.py index 4eb6a955..d88ba8b2 100644 --- a/backend/app/admin/crud/crud_user.py +++ b/backend/app/admin/crud/crud_user.py @@ -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( diff --git a/backend/cli.py b/backend/cli.py index f2dd5538..188b5743 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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) diff --git a/backend/core/conf.py b/backend/core/conf.py index c5563eda..f66c24ff 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 323ed073..70edddc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/plugin/core.py b/backend/plugin/core.py index 2a0ea38e..847bef82 100644 --- a/backend/plugin/core.py +++ b/backend/plugin/core.py @@ -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} 未启用,请联系系统管理员') diff --git a/backend/utils/dynamic_config.py b/backend/utils/dynamic_config.py index eefd861e..3c37fe58 100644 --- a/backend/utils/dynamic_config.py +++ b/backend/utils/dynamic_config.py @@ -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, diff --git a/backend/utils/dynamic_import.py b/backend/utils/dynamic_import.py index e8885091..33b48326 100644 --- a/backend/utils/dynamic_import.py +++ b/backend/utils/dynamic_import.py @@ -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: """ 获取模型对象 From 06f9751c8448f0f520e790ed3d9382620e77d2f7 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:50:04 +0800 Subject: [PATCH 4/6] Fix rbac permission checking logic (#1113) --- backend/common/security/rbac.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/common/security/rbac.py b/backend/common/security/rbac.py index 76d25c5b..1bee2f9e 100644 --- a/backend/common/security/rbac.py +++ b/backend/common/security/rbac.py @@ -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) From dcb68698e512b72bd2bfe8af0035be26eb7d0df1 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:50:20 +0800 Subject: [PATCH 5/6] Fix user menu permission code update (#1114) --- backend/app/admin/service/auth_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py index 75a8971a..742f5ee7 100644 --- a/backend/app/admin/service/auth_service.py +++ b/backend/app/admin/service/auth_service.py @@ -187,14 +187,14 @@ 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) From 2866fc45b17f9ff373e67784f49abc29a0d3ba21 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 19 Mar 2026 11:50:43 +0800 Subject: [PATCH 6/6] Fix typos in code and comments (#1115) --- backend/app/admin/model/opera_log.py | 6 +++--- backend/common/enums.py | 2 +- backend/common/i18n.py | 2 +- backend/middleware/access_middleware.py | 12 ++++++------ backend/plugin/code_generator/utils/gen_template.py | 3 ++- backend/utils/request_parse.py | 3 +++ 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/app/admin/model/opera_log.py b/backend/app/admin/model/opera_log.py index fd114b94..7963d864 100644 --- a/backend/app/admin/model/opera_log.py +++ b/backend/app/admin/model/opera_log.py @@ -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='设备') diff --git a/backend/common/enums.py b/backend/common/enums.py index 33ed2ed3..354e6aa1 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -93,7 +93,7 @@ class OperaLogCipherType(IntEnum): aes = 0 md5 = 1 itsdangerous = 2 - plan = 3 + plain = 3 class StatusType(IntEnum): diff --git a/backend/common/i18n.py b/backend/common/i18n.py index bcd46905..ccee328e 100644 --- a/backend/common/i18n.py +++ b/backend/common/i18n.py @@ -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: """ diff --git a/backend/middleware/access_middleware.py b/backend/middleware/access_middleware.py index 1def1a45..2f80243c 100644 --- a/backend/middleware/access_middleware.py +++ b/backend/middleware/access_middleware.py @@ -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() diff --git a/backend/plugin/code_generator/utils/gen_template.py b/backend/plugin/code_generator/utils/gen_template.py index eb3ed4ef..0a03f997 100644 --- a/backend/plugin/code_generator/utils/gen_template.py +++ b/backend/plugin/code_generator/utils/gen_template.py @@ -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)] diff --git a/backend/utils/request_parse.py b/backend/utils/request_parse.py index 1d68bec2..4939e933 100644 --- a/backend/utils/request_parse.py +++ b/backend/utils/request_parse.py @@ -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'