Compare commits

...
15 Commits
Author SHA1 Message Date
Wu Clan a58ca7f156 Update the version number to 1.13.2 (#1123) 2026-03-23 16:56:42 +08:00
Wu Clan c08f64ae75 Add sql scripts for OAuth2 plugin (#1121)
* Add sql scripts for OAuth2 plugin

* Fix lint
2026-03-23 16:46:58 +08:00
Wu Clan 48352563ef Update plugin config and sql validate (#1122)
* Update plugin config and sql validate

* Fix lint
2026-03-23 16:44:44 +08:00
Wu Clan f87d8f43c8 Fix zip plugin installation and unzip (#1120)
* Fix zip plugin installation and unzip

* FIx lint
2026-03-22 20:49:53 +08:00
Wu Clan 568e3ca66b Fix plugin status change cache missing (#1118) 2026-03-21 23:34:40 +08:00
Wu Clan a9acc31a78 Fix online api user id acquisition (#1116) 2026-03-19 12:12:20 +08:00
Wu Clan 2866fc45b1 Fix typos in code and comments (#1115) 2026-03-19 11:50:43 +08:00
Wu Clan dcb68698e5 Fix user menu permission code update (#1114) 2026-03-19 11:50:20 +08:00
Wu Clan 06f9751c84 Fix rbac permission checking logic (#1113) 2026-03-19 11:50:04 +08:00
Wu Clan 23975442d8 Update plugin dynamic import check (#1112)
* Update plugin dynamic import check

* Update cli dynamic import
2026-03-19 11:49:33 +08:00
Wu Clan 577a13b271 Update refresh token geo-checksum response (#1111) 2026-03-19 11:48:42 +08:00
Wu Clan 05bb98dada Update user id context setting location (#1110)
* Update user id context setting location

* Update jwt middleware

* Fix lint
2026-03-19 11:48:11 +08:00
Wu Clan 4abe09dbc3 Replace the uvicorn runner with granian (#1105) 2026-03-09 21:59:00 +08:00
Wu Clan 2bedeef26c Fix log type check and default formatter (#1104) 2026-03-09 21:58:27 +08:00
Wu Clan 8efcc394cc Update the changelog for v1.13.1 (#1103) 2026-03-08 18:50:31 +08:00
38 changed files with 10640 additions and 3666 deletions
+5 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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,
+10 -9
View File
@@ -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(
+3 -3
View File
@@ -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='设备')
+20 -8
View File
@@ -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:
+13 -2
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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
View File
@@ -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',
+22 -21
View File
@@ -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)
+10 -12
View File
@@ -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)
+5 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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()
+6 -6
View File
@@ -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()
+1 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+21 -11
View File
@@ -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,
-19
View File
@@ -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:
""" """
获取模型对象 获取模型对象
+3
View File
@@ -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'
Generated
+9442 -2601
View File
File diff suppressed because it is too large Load Diff