diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index ea95718..d68f083 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -46,28 +46,39 @@ JWT_REDIS_EXPIRE_MINUTES = 30 # -------- 数据库配置 -------- -# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' -DB_TYPE = 'mysql' -# 数据库主机 -DB_HOST = '127.0.0.1' -# 数据库端口 -DB_PORT = 3306 -# 数据库用户名 -DB_USERNAME = 'root' -# 数据库密码 -DB_PASSWORD = 'mysqlroot' -# 数据库名称 -DB_DATABASE = 'ruoyi-fastapi' -# 是否开启sqlalchemy日志 -DB_ECHO = true -# 允许溢出连接池大小的最大连接数 -DB_MAX_OVERFLOW = 10 -# 连接池大小,0表示连接数无限制 -DB_POOL_SIZE = 50 -# 连接回收时间(单位:秒) -DB_POOL_RECYCLE = 3600 -# 连接池中没有线程可用时,最多等待的时间(单位:秒) -DB_POOL_TIMEOUT = 30 +# 默认数据源名称 +DB_DEFAULT_SOURCE = 'primary' +# 数据源配置(每个节点完整保留数据库连接字段) +# db_type:数据库类型,可选 mysql、postgresql +# db_host:数据库主机地址 +# db_port:数据库端口 +# db_username:数据库用户名 +# db_password:数据库密码 +# db_database:数据库名称 +# db_echo:是否输出SQLAlchemy SQL日志 +# db_connect_timeout:建立数据库连接的超时时间(秒) +# db_max_overflow:连接池允许的最大溢出连接数 +# db_pool_size:连接池常驻连接数 +# db_pool_recycle:连接回收间隔(秒),-1表示禁用回收 +# db_pool_timeout:获取连接的超时时间(秒) +# db_required:启动时连接失败是否阻止应用启动 +DB_SOURCES = '{ + "primary": { + "db_type": "mysql", + "db_host": "127.0.0.1", + "db_port": 3306, + "db_username": "root", + "db_password": "mysqlroot", + "db_database": "ruoyi-fastapi", + "db_echo": true, + "db_connect_timeout": 10, + "db_max_overflow": 10, + "db_pool_size": 50, + "db_pool_recycle": 3600, + "db_pool_timeout": 30, + "db_required": true + } +}' # -------- Redis配置 -------- # Redis主机 diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index fd2dcfa..895b717 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -46,28 +46,39 @@ JWT_REDIS_EXPIRE_MINUTES = 30 # -------- 数据库配置 -------- -# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' -DB_TYPE = 'mysql' -# 数据库主机 -DB_HOST = 'ruoyi-mysql' -# 数据库端口 -DB_PORT = 3306 -# 数据库用户名 -DB_USERNAME = 'root' -# 数据库密码 -DB_PASSWORD = 'root' -# 数据库名称 -DB_DATABASE = 'ruoyi-fastapi' -# 是否开启sqlalchemy日志 -DB_ECHO = true -# 允许溢出连接池大小的最大连接数 -DB_MAX_OVERFLOW = 10 -# 连接池大小,0表示连接数无限制 -DB_POOL_SIZE = 50 -# 连接回收时间(单位:秒) -DB_POOL_RECYCLE = 3600 -# 连接池中没有线程可用时,最多等待的时间(单位:秒) -DB_POOL_TIMEOUT = 30 +# 默认数据源名称 +DB_DEFAULT_SOURCE = 'primary' +# 数据源配置(每个节点完整保留数据库连接字段) +# db_type:数据库类型,可选 mysql、postgresql +# db_host:数据库主机地址 +# db_port:数据库端口 +# db_username:数据库用户名 +# db_password:数据库密码 +# db_database:数据库名称 +# db_echo:是否输出SQLAlchemy SQL日志 +# db_connect_timeout:建立数据库连接的超时时间(秒) +# db_max_overflow:连接池允许的最大溢出连接数 +# db_pool_size:连接池常驻连接数 +# db_pool_recycle:连接回收间隔(秒),-1表示禁用回收 +# db_pool_timeout:获取连接的超时时间(秒) +# db_required:启动时连接失败是否阻止应用启动 +DB_SOURCES = '{ + "primary": { + "db_type": "mysql", + "db_host": "ruoyi-mysql", + "db_port": 3306, + "db_username": "root", + "db_password": "root", + "db_database": "ruoyi-fastapi", + "db_echo": true, + "db_connect_timeout": 10, + "db_max_overflow": 10, + "db_pool_size": 50, + "db_pool_recycle": 3600, + "db_pool_timeout": 30, + "db_required": true + } +}' # -------- Redis配置 -------- # Redis主机 diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index a75ef1e..cd52d0b 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -46,28 +46,39 @@ JWT_REDIS_EXPIRE_MINUTES = 30 # -------- 数据库配置 -------- -# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' -DB_TYPE = 'postgresql' -# 数据库主机 -DB_HOST = 'ruoyi-pg' -# 数据库端口 -DB_PORT = 5432 -# 数据库用户名 -DB_USERNAME = 'postgres' -# 数据库密码 -DB_PASSWORD = 'root' -# 数据库名称 -DB_DATABASE = 'ruoyi-fastapi' -# 是否开启sqlalchemy日志 -DB_ECHO = true -# 允许溢出连接池大小的最大连接数 -DB_MAX_OVERFLOW = 10 -# 连接池大小,0表示连接数无限制 -DB_POOL_SIZE = 50 -# 连接回收时间(单位:秒) -DB_POOL_RECYCLE = 3600 -# 连接池中没有线程可用时,最多等待的时间(单位:秒) -DB_POOL_TIMEOUT = 30 +# 默认数据源名称 +DB_DEFAULT_SOURCE = 'primary' +# 数据源配置(每个节点完整保留数据库连接字段) +# db_type:数据库类型,可选 mysql、postgresql +# db_host:数据库主机地址 +# db_port:数据库端口 +# db_username:数据库用户名 +# db_password:数据库密码 +# db_database:数据库名称 +# db_echo:是否输出SQLAlchemy SQL日志 +# db_connect_timeout:建立数据库连接的超时时间(秒) +# db_max_overflow:连接池允许的最大溢出连接数 +# db_pool_size:连接池常驻连接数 +# db_pool_recycle:连接回收间隔(秒),-1表示禁用回收 +# db_pool_timeout:获取连接的超时时间(秒) +# db_required:启动时连接失败是否阻止应用启动 +DB_SOURCES = '{ + "primary": { + "db_type": "postgresql", + "db_host": "ruoyi-pg", + "db_port": 5432, + "db_username": "postgres", + "db_password": "root", + "db_database": "ruoyi-fastapi", + "db_echo": true, + "db_connect_timeout": 10, + "db_max_overflow": 10, + "db_pool_size": 50, + "db_pool_recycle": 3600, + "db_pool_timeout": 30, + "db_required": true + } +}' # -------- Redis配置 -------- # Redis主机 diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index b75f45e..542f3a9 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -46,28 +46,39 @@ JWT_REDIS_EXPIRE_MINUTES = 30 # -------- 数据库配置 -------- -# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' -DB_TYPE = 'mysql' -# 数据库主机 -DB_HOST = '127.0.0.1' -# 数据库端口 -DB_PORT = 3306 -# 数据库用户名 -DB_USERNAME = 'root' -# 数据库密码 -DB_PASSWORD = 'root' -# 数据库名称 -DB_DATABASE = 'ruoyi-fastapi' -# 是否开启sqlalchemy日志 -DB_ECHO = true -# 允许溢出连接池大小的最大连接数 -DB_MAX_OVERFLOW = 10 -# 连接池大小,0表示连接数无限制 -DB_POOL_SIZE = 50 -# 连接回收时间(单位:秒) -DB_POOL_RECYCLE = 3600 -# 连接池中没有线程可用时,最多等待的时间(单位:秒) -DB_POOL_TIMEOUT = 30 +# 默认数据源名称 +DB_DEFAULT_SOURCE = 'primary' +# 数据源配置(每个节点完整保留数据库连接字段) +# db_type:数据库类型,可选 mysql、postgresql +# db_host:数据库主机地址 +# db_port:数据库端口 +# db_username:数据库用户名 +# db_password:数据库密码 +# db_database:数据库名称 +# db_echo:是否输出SQLAlchemy SQL日志 +# db_connect_timeout:建立数据库连接的超时时间(秒) +# db_max_overflow:连接池允许的最大溢出连接数 +# db_pool_size:连接池常驻连接数 +# db_pool_recycle:连接回收间隔(秒),-1表示禁用回收 +# db_pool_timeout:获取连接的超时时间(秒) +# db_required:启动时连接失败是否阻止应用启动 +DB_SOURCES = '{ + "primary": { + "db_type": "mysql", + "db_host": "127.0.0.1", + "db_port": 3306, + "db_username": "root", + "db_password": "root", + "db_database": "ruoyi-fastapi", + "db_echo": true, + "db_connect_timeout": 10, + "db_max_overflow": 10, + "db_pool_size": 50, + "db_pool_recycle": 3600, + "db_pool_timeout": 30, + "db_required": true + } +}' # -------- Redis配置 -------- # Redis主机 diff --git a/ruoyi-fastapi-backend/alembic/env.py b/ruoyi-fastapi-backend/alembic/env.py index 39c6cf1..5ddb691 100644 --- a/ruoyi-fastapi-backend/alembic/env.py +++ b/ruoyi-fastapi-backend/alembic/env.py @@ -7,11 +7,10 @@ from alembic import context from alembic.migration import MigrationContext from alembic.operations.ops import MigrationScript -from sqlalchemy import pool from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config -from config.database import ASYNC_SQLALCHEMY_DATABASE_URL, Base +from config.database import Base, DataSourceRegistry, build_async_sqlalchemy_database_url +from config.env import DataBaseConfig from utils.import_util import ImportUtil # 判断vesrions目录是否存在,如果不存在则创建 @@ -35,7 +34,20 @@ # add your model's MetaData object here # for 'autogenerate' support target_metadata = Base.metadata -# ASYNC_SQLALCHEMY_DATABASE_URL = 'mysql+asyncmy://root:mysqlroot@127.0.0.1:3306/ruoyi-fastapi' + + +def _default_source_url() -> str: + """ + 构建默认数据源的Alembic数据库连接URL + + :return: Alembic数据库连接URL + """ + database_url = build_async_sqlalchemy_database_url(DataBaseConfig.default_source) + return database_url.render_as_string(hide_password=False).replace('%', '%%') + + +ASYNC_SQLALCHEMY_DATABASE_URL = _default_source_url() + # other values from the config, defined by the needs of env.py, alembic_config.set_main_option('sqlalchemy.url', ASYNC_SQLALCHEMY_DATABASE_URL) @@ -111,16 +123,12 @@ async def run_async_migrations() -> None: """ - connectable = async_engine_from_config( - alembic_config.get_section(alembic_config.config_ini_section, {}), - prefix='sqlalchemy.', - poolclass=pool.NullPool, - ) + connectable = DataSourceRegistry.get_async_engine(DataBaseConfig.db_default_source) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) - await connectable.dispose() + await DataSourceRegistry.dispose_all() def run_migrations_online() -> None: diff --git a/ruoyi-fastapi-backend/cli/core/context_factory.py b/ruoyi-fastapi-backend/cli/core/context_factory.py index 23adf62..dc5dbef 100644 --- a/ruoyi-fastapi-backend/cli/core/context_factory.py +++ b/ruoyi-fastapi-backend/cli/core/context_factory.py @@ -49,7 +49,7 @@ def suppress_sqlalchemy_logs(self) -> None: 该逻辑同时处理两类来源: - 1. 将 `config.env.DataBaseConfig.db_echo` 强制关闭,避免后续新建 Engine 时 + 1. 将 `config.env.DataBaseConfig.db_sources` 中全部数据源的 `db_echo` 强制关闭,避免后续新建 Engine 时 继续打开 SQLAlchemy echo。 2. 将已知 SQLAlchemy logger 级别提升到 WARNING,避免已有 logger 配置把 `INFO sqlalchemy.engine.Engine ...` 继续打到标准输出。 @@ -59,9 +59,9 @@ def suppress_sqlalchemy_logs(self) -> None: if self.sqlalchemy_logs_suppressed: return env_module = import_module('config.env') - database_config = getattr(env_module, 'DataBaseConfig', None) - if database_config is not None and hasattr(database_config, 'db_echo'): - database_config.db_echo = False + database_config = env_module.DataBaseConfig + for source_config in database_config.db_sources.values(): + source_config.db_echo = False for logger_name in ( 'sqlalchemy', 'sqlalchemy.engine', diff --git a/ruoyi-fastapi-backend/cli/runtime/app/support.py b/ruoyi-fastapi-backend/cli/runtime/app/support.py index c7b3a6f..3af2563 100644 --- a/ruoyi-fastapi-backend/cli/runtime/app/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/app/support.py @@ -42,7 +42,7 @@ def build_app_config_snapshot(self) -> dict[str, Any]: """ env_module = self.infrastructure_gateway.get_env_module() app_config = env_module.AppConfig - database_config = env_module.DataBaseConfig + database_config = env_module.DataBaseConfig.default_source log_config = env_module.LogConfig redis_config = env_module.RedisConfig transport_crypto_config = env_module.TransportCryptoConfig diff --git a/ruoyi-fastapi-backend/cli/runtime/config/gateway.py b/ruoyi-fastapi-backend/cli/runtime/config/gateway.py index bd4eb6c..7740e3f 100644 --- a/ruoyi-fastapi-backend/cli/runtime/config/gateway.py +++ b/ruoyi-fastapi-backend/cli/runtime/config/gateway.py @@ -53,7 +53,7 @@ def get_async_session_local() -> Any: :return: 异步数据库会话工厂 """ - return import_module('config.database').AsyncSessionLocal + return import_module('config.database').DataSourceRegistry.session @staticmethod def get_redis_util() -> Any: diff --git a/ruoyi-fastapi-backend/cli/runtime/gen/gateway.py b/ruoyi-fastapi-backend/cli/runtime/gen/gateway.py index e099a6a..3bfb95c 100644 --- a/ruoyi-fastapi-backend/cli/runtime/gen/gateway.py +++ b/ruoyi-fastapi-backend/cli/runtime/gen/gateway.py @@ -35,7 +35,7 @@ def get_async_session_local() -> Any: :return: 异步数据库会话工厂 """ - return import_module('config.database').AsyncSessionLocal + return import_module('config.database').DataSourceRegistry.session @staticmethod def get_page_model() -> Any: @@ -53,7 +53,7 @@ def get_database_config() -> Any: :return: 数据库配置对象 """ - return import_module('config.env').DataBaseConfig + return import_module('config.env').DataBaseConfig.default_source @staticmethod def get_gen_config() -> Any: diff --git a/ruoyi-fastapi-backend/cli/runtime/job/gateway.py b/ruoyi-fastapi-backend/cli/runtime/job/gateway.py index b2491d4..6c538a1 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/gateway.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/gateway.py @@ -26,7 +26,7 @@ def get_async_session_local() -> Any: :return: 异步数据库会话工厂 """ - return import_module('config.database').AsyncSessionLocal + return import_module('config.database').DataSourceRegistry.session @staticmethod def get_redis_util() -> Any: diff --git a/ruoyi-fastapi-backend/common/aspect/db_seesion.py b/ruoyi-fastapi-backend/common/aspect/db_seesion.py deleted file mode 100644 index 3c88fb9..0000000 --- a/ruoyi-fastapi-backend/common/aspect/db_seesion.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import Depends, params - -from config.get_db import get_db - - -def DBSessionDependency() -> params.Depends: # noqa: N802 - """ - 数据库会话依赖 - - :return: 数据库会话依赖 - """ - return Depends(get_db) diff --git a/ruoyi-fastapi-backend/common/aspect/db_session.py b/ruoyi-fastapi-backend/common/aspect/db_session.py new file mode 100644 index 0000000..526777b --- /dev/null +++ b/ruoyi-fastapi-backend/common/aspect/db_session.py @@ -0,0 +1,46 @@ +from collections.abc import AsyncIterator +from functools import cache + +from fastapi import Depends, params +from sqlalchemy.ext.asyncio import AsyncSession + +from config.database import DataSourceRegistry + + +class DBSessionProvider: + """ + 数据库会话依赖提供者 + """ + + def __init__(self, source_name: str | None = None) -> None: + self.source_name = source_name + + async def __call__(self) -> AsyncIterator[AsyncSession]: + """ + 创建指定数据源的数据库会话 + + :return: 异步数据库会话 + """ + async with DataSourceRegistry.session(self.source_name) as session: + yield session + + +@cache +def get_db_session_provider(source_name: str | None) -> DBSessionProvider: + """ + 获取指定数据源的数据库会话依赖提供者 + + :param source_name: 数据源名称 + :return: 数据库会话依赖提供者 + """ + return DBSessionProvider(source_name) + + +def DBSessionDependency(source_name: str | None = None) -> params.Depends: # noqa: N802 + """ + 数据库会话依赖 + + :param source_name: 数据源名称,为空时使用默认数据源 + :return: 数据库会话依赖 + """ + return Depends(get_db_session_provider(source_name)) diff --git a/ruoyi-fastapi-backend/common/aspect/pre_auth.py b/ruoyi-fastapi-backend/common/aspect/pre_auth.py index cdcc579..b17a8bb 100644 --- a/ruoyi-fastapi-backend/common/aspect/pre_auth.py +++ b/ruoyi-fastapi-backend/common/aspect/pre_auth.py @@ -5,9 +5,9 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession +from common.aspect.db_session import DBSessionDependency from common.context import RequestContext from config.env import AppConfig -from config.get_db import get_db from exceptions.exception import AuthException from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.service.login_service import LoginService @@ -80,7 +80,7 @@ def _compile_path_pattern(self, path: str) -> re.Pattern: # 添加开始和结束锚点,确保精确匹配 return re.compile(f'^{pattern_str}$') - async def __call__(self, request: Request, db: AsyncSession = Depends(get_db)) -> CurrentUserModel | None: + async def __call__(self, request: Request, db: AsyncSession = DBSessionDependency()) -> CurrentUserModel | None: """ 执行登录认证校验 diff --git a/ruoyi-fastapi-backend/common/constant.py b/ruoyi-fastapi-backend/common/constant.py index 279132c..aee6467 100644 --- a/ruoyi-fastapi-backend/common/constant.py +++ b/ruoyi-fastapi-backend/common/constant.py @@ -1,6 +1,3 @@ -from config.env import DataBaseConfig - - class CommonConstant: """ 常用常量 @@ -614,16 +611,17 @@ class GenConstant: PARENT_MENU_ID = 'parentMenuId' PARENT_MENU_NAME = 'parentMenuName' GEN_VIEW = 'genView' - COLUMNTYPE_STR = ( - ['character varying', 'varchar', 'character', 'char'] - if DataBaseConfig.db_type == 'postgresql' - else ['char', 'varchar', 'nvarchar', 'varchar2'] - ) - COLUMNTYPE_TEXT = ( - ['text', 'citext'] if DataBaseConfig.db_type == 'postgresql' else ['tinytext', 'text', 'mediumtext', 'longtext'] - ) - COLUMNTYPE_TIME = ( - [ + COLUMNTYPE_STR = { + 'mysql': ['char', 'varchar', 'nvarchar', 'varchar2'], + 'postgresql': ['character varying', 'varchar', 'character', 'char'], + } + COLUMNTYPE_TEXT = { + 'mysql': ['tinytext', 'text', 'mediumtext', 'longtext'], + 'postgresql': ['text', 'citext'], + } + COLUMNTYPE_TIME = { + 'mysql': ['datetime', 'time', 'date', 'timestamp'], + 'postgresql': [ 'date', 'time', 'time with time zone', @@ -632,14 +630,10 @@ class GenConstant: 'timestamp with time zone', 'timestamp without time zone', 'interval', - ] - if DataBaseConfig.db_type == 'postgresql' - else ['datetime', 'time', 'date', 'timestamp'] - ) - COLUMNTYPE_GEOMETRY = ( - ['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle'] - if DataBaseConfig.db_type == 'postgresql' - else [ + ], + } + COLUMNTYPE_GEOMETRY = { + 'mysql': [ 'geometry', 'point', 'linestring', @@ -648,21 +642,35 @@ class GenConstant: 'multilinestring', 'multipolygon', 'geometrycollection', - ] - ) - COLUMNTYPE_NUMBER = [ - 'tinyint', - 'smallint', - 'mediumint', - 'int', - 'number', - 'integer', - 'bit', - 'bigint', - 'float', - 'double', - 'decimal', - ] + ], + 'postgresql': ['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle'], + } + COLUMNTYPE_NUMBER = { + 'mysql': [ + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'number', + 'integer', + 'bit', + 'bigint', + 'float', + 'double', + 'decimal', + ], + 'postgresql': [ + 'smallint', + 'integer', + 'bigint', + 'real', + 'double precision', + 'numeric', + 'decimal', + 'boolean', + 'bit', + ], + } COLUMNNAME_NOT_ADD_SHOW = ['create_by', 'create_time'] COLUMNNAME_NOT_EDIT_SHOW = ['update_by', 'update_time'] COLUMNNAME_NOT_EDIT = ['id', 'create_by', 'create_time', 'del_flag'] @@ -684,8 +692,8 @@ class GenConstant: QUERY_LIKE = 'LIKE' QUERY_EQ = 'EQ' REQUIRE = '1' - DB_TO_SQLALCHEMY_TYPE_MAPPING = ( - { + DB_TO_SQLALCHEMY_TYPE_MAPPING = { + 'postgresql': { 'boolean': 'Boolean', 'smallint': 'SmallInteger', 'integer': 'Integer', @@ -739,9 +747,8 @@ class GenConstant: 'int2vector': 'ARRAY', 'oidvector': 'ARRAY', 'pg_node_tree': 'Text', - } - if DataBaseConfig.db_type == 'postgresql' - else { + }, + 'mysql': { # 数值类型 'TINYINT': 'SmallInteger', 'SMALLINT': 'SmallInteger', @@ -786,10 +793,10 @@ class GenConstant: 'MULTILINESTRING': 'Geometry', 'MULTIPOLYGON': 'Geometry', 'GEOMETRYCOLLECTION': 'Geometry', - } - ) - DB_TO_PYTHON_TYPE_MAPPING = ( - { + }, + } + DB_TO_PYTHON_TYPE_MAPPING = { + 'postgresql': { 'boolean': 'bool', 'smallint': 'int', 'integer': 'int', @@ -843,9 +850,8 @@ class GenConstant: 'int2vector': 'list', 'oidvector': 'list', 'pg_node_tree': 'str', - } - if DataBaseConfig.db_type == 'postgresql' - else { + }, + 'mysql': { # 数值类型 'TINYINT': 'int', 'SMALLINT': 'int', @@ -890,5 +896,5 @@ class GenConstant: 'MULTILINESTRING': 'bytes', 'MULTIPOLYGON': 'bytes', 'GEOMETRYCOLLECTION': 'bytes', - } - ) + }, + } diff --git a/ruoyi-fastapi-backend/config/database.py b/ruoyi-fastapi-backend/config/database.py index e526b42..a9694e4 100644 --- a/ruoyi-fastapi-backend/config/database.py +++ b/ruoyi-fastapi-backend/config/database.py @@ -1,109 +1,588 @@ -from urllib.parse import quote_plus +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from functools import cache +from typing import Any -from sqlalchemy import Engine, create_engine -from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncEngine, async_sessionmaker, create_async_engine +from pydantic import SecretStr +from sqlalchemy import URL, Engine, create_engine, text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import ( + AsyncAttrs, + AsyncConnection, + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from sqlalchemy.orm import DeclarativeBase, sessionmaker -from config.env import DataBaseConfig +from config.env import DataBaseConfig, DataBaseSettings, DataSourceSettings +from exceptions.exception import ( + DataSourceException, + DataSourceInitializationException, + DataSourceNotFoundException, + DataSourceUnavailableException, +) +from utils.log_util import logger +_HEALTH_RETRY_COOLDOWN = timedelta(seconds=5) -def build_async_sqlalchemy_database_url() -> str: + +def _error_details(exc: BaseException) -> tuple[str, int | None]: + """ + 提取不含连接凭据和SQL参数的安全错误摘要 + + :param exc: 原始异常或数据源异常 + :return: 异常类型和数字错误码 + """ + if isinstance(exc, DataSourceException): + return exc.error_type or type(exc).__name__, exc.error_code + original = exc.orig if isinstance(exc, DBAPIError) else exc + error_code = original.args[0] if original.args and isinstance(original.args[0], int) else None + return type(original).__name__, error_code + + +def _error_log_suffix(error_type: str, error_code: int | None) -> str: """ - 构建异步 SQLAlchemy 数据库连接 URL + 构建数据源错误日志摘要 - :return: 异步 SQLAlchemy 数据库连接 URL + :param error_type: 异常类型 + :param error_code: 数字错误码 + :return: 不含敏感信息的日志后缀 """ - if DataBaseConfig.db_type == 'postgresql': - return ( - f'postgresql+asyncpg://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' - f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + code_text = f',错误码:{error_code}' if error_code is not None else '' + return f',错误类型:{error_type}{code_text}' + + +@dataclass(frozen=True, slots=True) +class DatabaseDriverAdapter: + """ + 数据库驱动适配器 + """ + + db_type: str + async_driver: str + sync_driver: str + async_connect_timeout_key: str + sync_connect_timeout_key: str + + def build_url(self, config: DataSourceSettings, *, sync: bool) -> URL: + """ + 根据数据源配置构建SQLAlchemy数据库连接URL + + :param config: 数据源配置 + :param sync: 是否构建同步数据库连接URL + :return: SQLAlchemy数据库连接URL + """ + return URL.create( + drivername=self.sync_driver if sync else self.async_driver, + username=config.db_username, + password=_secret_value(config.db_password), + host=config.db_host, + port=int(config.db_port), + database=config.db_database, ) - return ( - f'mysql+asyncmy://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' - f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' + + def build_connect_args(self, config: DataSourceSettings, *, sync: bool) -> dict[str, int]: + """ + 构建数据库驱动连接参数 + + :param config: 数据源配置 + :param sync: 是否构建同步数据库连接参数 + :return: 数据库驱动连接参数 + """ + timeout_key = self.sync_connect_timeout_key if sync else self.async_connect_timeout_key + return {timeout_key: config.db_connect_timeout} + + +_DATABASE_DRIVER_ADAPTERS = { + adapter.db_type: adapter + for adapter in ( + DatabaseDriverAdapter( + db_type='mysql', + async_driver='mysql+asyncmy', + sync_driver='mysql+pymysql', + async_connect_timeout_key='connect_timeout', + sync_connect_timeout_key='connect_timeout', + ), + DatabaseDriverAdapter( + db_type='postgresql', + async_driver='postgresql+asyncpg', + sync_driver='postgresql+psycopg2', + async_connect_timeout_key='timeout', + sync_connect_timeout_key='connect_timeout', + ), ) +} -ASYNC_SQLALCHEMY_DATABASE_URL = build_async_sqlalchemy_database_url() +def _secret_value(value: SecretStr | str) -> str: + """ + 获取密码配置的原始值 + + :param value: 密码配置 + :return: 密码原始值 + """ + return value.get_secret_value() if isinstance(value, SecretStr) else value -def build_sync_sqlalchemy_database_url() -> str: +def _database_source(config: DataBaseSettings | DataSourceSettings) -> DataSourceSettings: """ - 构建同步 SQLAlchemy 数据库连接 URL + 获取指定配置对应的数据源配置 - :return: 同步 SQLAlchemy 数据库连接 URL + :param config: 数据库集合配置或单个数据源配置 + :return: 单个数据源配置 """ - if DataBaseConfig.db_type == 'postgresql': - return ( - f'postgresql+psycopg2://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' - f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' - ) - return ( - f'mysql+pymysql://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@' - f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}' - ) + return config.get_source() if isinstance(config, DataBaseSettings) else config -SYNC_SQLALCHEMY_DATABASE_URL = build_sync_sqlalchemy_database_url() +def _driver_adapter(config: DataSourceSettings) -> DatabaseDriverAdapter: + """ + 获取数据库驱动适配器 + + :param config: 数据源配置 + :return: 数据库驱动适配器 + """ + adapter = _DATABASE_DRIVER_ADAPTERS.get(config.db_type) + if adapter is None: + raise ValueError(f'不支持的数据库类型:{config.db_type!r}') + return adapter + + +def _build_url(config: DataSourceSettings, *, sync: bool) -> URL: + """ + 使用对应的数据库驱动适配器构建连接URL + + :param config: 数据源配置 + :param sync: 是否构建同步数据库连接URL + :return: SQLAlchemy数据库连接URL + """ + return _driver_adapter(config).build_url(config, sync=sync) + + +def build_async_sqlalchemy_database_url(config: DataBaseSettings | DataSourceSettings | None = None) -> URL: + """ + 构建异步SQLAlchemy数据库连接URL + + :param config: 数据库集合配置或单个数据源配置 + :return: 异步SQLAlchemy数据库连接URL + """ + return _build_url(_database_source(config or DataBaseConfig), sync=False) + + +def build_sync_sqlalchemy_database_url(config: DataBaseSettings | DataSourceSettings | None = None) -> URL: + """ + 构建同步SQLAlchemy数据库连接URL + + :param config: 数据库集合配置或单个数据源配置 + :return: 同步SQLAlchemy数据库连接URL + """ + return _build_url(_database_source(config or DataBaseConfig), sync=True) + + +def _engine_options(config: DataSourceSettings, echo: bool | None = None) -> dict[str, Any]: + """ + 构建数据库引擎连接池参数 + + :param config: 数据源配置 + :param echo: 是否输出SQLAlchemy SQL日志 + :return: 数据库引擎连接池参数 + """ + return { + 'echo': config.db_echo if echo is None else echo, + 'max_overflow': config.db_max_overflow, + 'pool_size': config.db_pool_size, + 'pool_recycle': config.db_pool_recycle, + 'pool_timeout': config.db_pool_timeout, + 'pool_pre_ping': True, + 'pool_use_lifo': True, + } -def create_async_db_engine(echo: bool | None = None) -> AsyncEngine: +def create_async_db_engine( + echo: bool | None = None, config: DataBaseSettings | DataSourceSettings | None = None +) -> AsyncEngine: """ - 创建异步 SQLAlchemy Engine + 创建异步SQLAlchemy Engine - :param echo: 可选,是否输出 SQLAlchemy SQL 日志 - :return: 异步 SQLAlchemy Engine + :param echo: 是否输出SQLAlchemy SQL日志 + :param config: 数据库集合配置或单个数据源配置 + :return: 异步SQLAlchemy Engine """ + source = _database_source(config or DataBaseConfig) + adapter = _driver_adapter(source) return create_async_engine( - ASYNC_SQLALCHEMY_DATABASE_URL, - echo=DataBaseConfig.db_echo if echo is None else echo, - max_overflow=DataBaseConfig.db_max_overflow, - pool_size=DataBaseConfig.db_pool_size, - pool_recycle=DataBaseConfig.db_pool_recycle, - pool_timeout=DataBaseConfig.db_pool_timeout, + adapter.build_url(source, sync=False), + connect_args=adapter.build_connect_args(source, sync=False), + **_engine_options(source, echo), ) -def create_sync_db_engine(echo: bool | None = None) -> Engine: +def create_sync_db_engine( + echo: bool | None = None, config: DataBaseSettings | DataSourceSettings | None = None +) -> Engine: """ - 创建同步 SQLAlchemy Engine + 创建同步SQLAlchemy Engine - :param echo: 可选,是否输出 SQLAlchemy SQL 日志 - :return: 同步 SQLAlchemy Engine + :param echo: 是否输出SQLAlchemy SQL日志 + :param config: 数据库集合配置或单个数据源配置 + :return: 同步SQLAlchemy Engine """ + source = _database_source(config or DataBaseConfig) + adapter = _driver_adapter(source) return create_engine( - SYNC_SQLALCHEMY_DATABASE_URL, - echo=DataBaseConfig.db_echo if echo is None else echo, - max_overflow=DataBaseConfig.db_max_overflow, - pool_size=DataBaseConfig.db_pool_size, - pool_recycle=DataBaseConfig.db_pool_recycle, - pool_timeout=DataBaseConfig.db_pool_timeout, + adapter.build_url(source, sync=True), + connect_args=adapter.build_connect_args(source, sync=True), + **_engine_options(source, echo), ) -def create_async_session_local(engine: AsyncEngine) -> async_sessionmaker: +def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: """ - 创建异步 Session 工厂 + 创建异步Session工厂 - :param engine: 异步 SQLAlchemy Engine - :return: 异步 Session 工厂 + :param engine: 异步SQLAlchemy Engine + :return: 异步Session工厂 """ - return async_sessionmaker(autocommit=False, autoflush=False, bind=engine) + return async_sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) -def create_sync_session_local(engine: Engine) -> sessionmaker: +def create_sync_session_factory(engine: Engine) -> sessionmaker: """ - 创建同步 Session 工厂 + 创建同步Session工厂 - :param engine: 同步 SQLAlchemy Engine - :return: 同步 Session 工厂 + :param engine: 同步SQLAlchemy Engine + :return: 同步Session工厂 """ - return sessionmaker(autocommit=False, autoflush=False, bind=engine) + return sessionmaker(bind=engine, autocommit=False, autoflush=False) -async_engine = create_async_db_engine() -AsyncSessionLocal = create_async_session_local(async_engine) +@dataclass(slots=True) +class DataSourceRuntime: + """ + 数据源运行时状态 + """ + + name: str + config: DataSourceSettings + async_engine: AsyncEngine | None = None + async_session_factory: async_sessionmaker[AsyncSession] | None = None + sync_engine: Engine | None = None + available: bool = False + last_health_check_at: datetime | None = None + next_retry_at: datetime | None = None + health_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class _DataSourceRegistry: + """ + 数据源注册中心 + """ + + def __init__(self, settings: DataBaseSettings | None = None) -> None: + self.settings = settings or DataBaseConfig + self._runtimes: dict[str, DataSourceRuntime] = {} + self._initialized = False + self._log_enabled = True + self._configs = dict(self.settings.db_sources) + + def _resolve_name(self, name: str | None = None) -> str: + """ + 解析并校验数据源名称 + + :param name: 数据源名称 + :return: 已配置的数据源名称 + """ + source_name = name or self.settings.db_default_source + if source_name not in self._configs: + raise DataSourceNotFoundException(source_name) + return source_name + + def _runtime(self, name: str | None = None) -> DataSourceRuntime: + """ + 获取或创建数据源运行时状态 + + :param name: 数据源名称 + :return: 数据源运行时状态 + """ + source_name = self._resolve_name(name) + runtime = self._runtimes.get(source_name) + if runtime is None: + runtime = DataSourceRuntime(name=source_name, config=self._configs[source_name]) + self._runtimes[source_name] = runtime + return runtime + + @staticmethod + def _mark_unavailable(runtime: DataSourceRuntime) -> None: + """ + 标记数据源不可用并设置下次重试时间 + + :param runtime: 数据源运行时状态 + :return: None + """ + now = datetime.now(timezone.utc) + runtime.available = False + runtime.last_health_check_at = now + runtime.next_retry_at = now + _HEALTH_RETRY_COOLDOWN + + @staticmethod + def _data_source_error( + exception_type: type[DataSourceException], + runtime: DataSourceRuntime, + exc: BaseException, + ) -> DataSourceException: + """将底层异常转换为不泄露连接信息的数据源异常。""" + error_type, error_code = _error_details(exc) + return exception_type(runtime.name, error_type=error_type, error_code=error_code) + + @classmethod + def _ensure_async_resources(cls, runtime: DataSourceRuntime) -> None: + """ + 确保数据源异步引擎和Session工厂已创建 + + :param runtime: 数据源运行时状态 + :return: None + """ + if runtime.async_engine is not None and runtime.async_session_factory is not None: + return + try: + engine = create_async_db_engine(config=runtime.config) + session_factory = create_async_session_factory(engine) + except Exception as exc: + cls._mark_unavailable(runtime) + raise cls._data_source_error(DataSourceInitializationException, runtime, exc) from None + runtime.async_engine = engine + runtime.async_session_factory = session_factory + + async def initialize(self, log_enabled: bool = True) -> None: + """ + 初始化并检查所有数据源的连接状态 + + :param log_enabled: 是否输出数据源启动日志 + :return: None + """ + self._log_enabled = log_enabled + if self._initialized: + return + names = tuple(self._configs) + results = await asyncio.gather(*(self._check_health(name) for name in names), return_exceptions=True) + default_name = self._resolve_name() + required_failure: tuple[str, BaseException] | None = None + for name, result in zip(names, results, strict=True): + config = self._configs[name] + required = config.db_required or name == default_name + healthy = not isinstance(result, BaseException) + error_type = None + error_code = None + if not healthy: + error_type, error_code = _error_details(result) + if log_enabled: + log_context: dict[str, Any] = { + 'data_source': name, + 'database_type': config.db_type, + 'required': required, + } + if error_type is not None: + log_context['error_type'] = error_type + log_context['error_code'] = error_code + source_logger = logger.bind(**log_context) + if healthy: + source_logger.info(f'✅ 数据源 {name} 初始化成功') + elif required: + source_logger.error(f'❌ 必需数据源 {name} 连接检查失败{_error_log_suffix(error_type, error_code)}') + else: + source_logger.warning( + f'⚠️ 非必需数据源 {name} 连接检查失败,应用将降级启动{_error_log_suffix(error_type, error_code)}' + ) + if not healthy and required and required_failure is None: + required_failure = (name, result) + if required_failure is not None: + name, result = required_failure + error_type, error_code = _error_details(result) + await self.dispose_all() + raise DataSourceInitializationException( + name, + error_type=error_type, + error_code=error_code, + ) from None + self._initialized = True + + def get_async_engine(self, name: str | None = None) -> AsyncEngine: + """ + 获取数据源异步引擎 + + :param name: 数据源名称 + :return: 异步SQLAlchemy Engine + """ + runtime = self._runtime(name) + self._ensure_async_resources(runtime) + assert runtime.async_engine is not None + return runtime.async_engine + + def get_sync_engine(self, name: str | None = None) -> Engine: + """ + 获取数据源同步引擎 + + :param name: 数据源名称 + :return: 同步SQLAlchemy Engine + """ + runtime = self._runtime(name) + if runtime.sync_engine is None: + try: + runtime.sync_engine = create_sync_db_engine(config=runtime.config) + except Exception as exc: + raise self._data_source_error(DataSourceInitializationException, runtime, exc) from None + return runtime.sync_engine + + async def _check_health(self, name: str) -> None: + """ + 检查指定数据源的连接状态 + + :param name: 数据源名称 + :return: None + """ + runtime = self._runtime(name) + async with runtime.health_lock: + await self._check_health_locked(runtime) + + async def _check_health_locked(self, runtime: DataSourceRuntime) -> None: + """ + 在持有健康检查锁时检查数据源连接状态 + + :param runtime: 数据源运行时状态 + :return: None + """ + try: + self._ensure_async_resources(runtime) + assert runtime.async_engine is not None + async with runtime.async_engine.begin() as connection: + await connection.execute(text('SELECT 1')) + except Exception as exc: + self._mark_unavailable(runtime) + raise self._data_source_error(DataSourceUnavailableException, runtime, exc) from None + runtime.available = True + runtime.last_health_check_at = datetime.now(timezone.utc) + runtime.next_retry_at = None + + async def _ensure_available(self, runtime: DataSourceRuntime) -> None: + """ + 确保指定数据源当前可用 + + :param runtime: 数据源运行时状态 + :return: None + """ + async with runtime.health_lock: + if runtime.available: + return + now = datetime.now(timezone.utc) + if runtime.next_retry_at is not None and now < runtime.next_retry_at: + raise DataSourceUnavailableException(runtime.name) + await self._check_health_locked(runtime) + if self._log_enabled: + logger.bind(data_source=runtime.name).info(f'✅ 数据源 {runtime.name} 连接已恢复') + + @asynccontextmanager + async def connection(self, name: str | None = None) -> AsyncGenerator[AsyncConnection, None]: + """ + 创建指定数据源的异步数据库连接事务 + + :param name: 数据源名称 + :return: 异步数据库连接 + """ + runtime = self._runtime(name) + await self._ensure_available(runtime) + assert runtime.async_engine is not None + try: + async with runtime.async_engine.begin() as connection: + yield connection + except DBAPIError as exc: + if not exc.connection_invalidated: + raise + self._mark_unavailable(runtime) + raise self._data_source_error(DataSourceUnavailableException, runtime, exc) from None + + @asynccontextmanager + async def session(self, name: str | None = None) -> AsyncGenerator[AsyncSession, None]: + """ + 创建指定数据源的异步数据库会话 + + :param name: 数据源名称 + :return: 异步数据库会话 + """ + runtime = self._runtime(name) + await self._ensure_available(runtime) + factory = runtime.async_session_factory + assert factory is not None + try: + async with factory() as current_db: + yield current_db + except DBAPIError as exc: + if not exc.connection_invalidated: + raise + self._mark_unavailable(runtime) + raise self._data_source_error(DataSourceUnavailableException, runtime, exc) from None + + async def dispose_all(self) -> None: + """ + 释放所有数据源的同步和异步引擎 + + :return: None + """ + runtimes = tuple(self._runtimes.values()) + self._runtimes.clear() + self._initialized = False + for runtime in runtimes: + if runtime.sync_engine is not None: + try: + runtime.sync_engine.dispose() + except Exception as exc: + error_type, error_code = _error_details(exc) + logger.bind( + data_source=runtime.name, + engine_type='sync', + error_type=error_type, + error_code=error_code, + ).warning(f'⚠️ 数据源 {runtime.name} 同步Engine释放失败{_error_log_suffix(error_type, error_code)}') + async_resources = [(runtime, engine) for runtime in runtimes if (engine := runtime.async_engine) is not None] + results = await asyncio.gather( + *(engine.dispose() for _, engine in async_resources), + return_exceptions=True, + ) + for (runtime, _), result in zip(async_resources, results, strict=True): + if isinstance(result, Exception): + error_type, error_code = _error_details(result) + logger.bind( + data_source=runtime.name, + engine_type='async', + error_type=error_type, + error_code=error_code, + ).warning(f'⚠️ 数据源 {runtime.name} 异步Engine释放失败{_error_log_suffix(error_type, error_code)}') + elif isinstance(result, BaseException): + raise result + + +DataSourceRegistry = _DataSourceRegistry() class Base(AsyncAttrs, DeclarativeBase): pass + + +@cache +def get_data_source_base(source_name: str) -> type[DeclarativeBase]: + """ + 获取指定数据源独立且可复用的ORM元数据基类 + + :param source_name: 数据源名称 + :return: ORM元数据基类 + """ + DataBaseConfig.get_source(source_name) + + class NamedDataSourceBase(AsyncAttrs, DeclarativeBase): + pass + + NamedDataSourceBase.__name__ = f'{source_name.title().replace("_", "").replace("-", "")}DataSourceBase' + return NamedDataSourceBase diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index ee8a299..9f8d4fa 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -1,13 +1,17 @@ import argparse import configparser +import json import os +import re import secrets import sys -from typing import Literal +from typing import Annotated, Literal from dotenv import load_dotenv -from pydantic import Field, computed_field, field_validator -from pydantic_settings import BaseSettings +from pydantic import BaseModel, ConfigDict, Field, SecretStr, computed_field, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + +from exceptions.exception import DataSourceNotFoundException class AppSettings(BaseSettings): @@ -58,31 +62,109 @@ def generate_empty_secret_key(cls, value: object) -> object: return value -class DataBaseSettings(BaseSettings): +class DataSourceSettings(BaseModel): """ - 数据库配置 + 单个数据源配置 """ - db_type: Literal['mysql', 'postgresql'] = 'mysql' - db_host: str = '127.0.0.1' - db_port: int = 3306 - db_username: str = 'root' - db_password: str = 'mysqlroot' - db_database: str = 'ruoyi-fastapi' + model_config = ConfigDict(hide_input_in_errors=True) + + db_type: Literal['mysql', 'postgresql'] + db_host: str = Field(min_length=1) + db_port: int = Field(ge=1, le=65535) + db_username: str = Field(min_length=1) + db_password: SecretStr + db_database: str = Field(min_length=1) + db_echo: bool = True - db_max_overflow: int = 10 - db_pool_size: int = 50 - db_pool_recycle: int = 3600 - db_pool_timeout: int = 30 + db_connect_timeout: int = Field(default=10, gt=0) + db_max_overflow: int = Field(default=10, ge=0) + db_pool_size: int = Field(default=20, ge=1) + db_pool_recycle: int = Field(default=3600, ge=-1) + db_pool_timeout: int = Field(default=30, gt=0) + db_required: bool = True @computed_field @property def sqlglot_parse_dialect(self) -> str: + """ + 获取SQLGlot解析方言 + + :return: SQLGlot解析方言 + """ if self.db_type == 'postgresql': return 'postgres' return self.db_type +DATA_SOURCE_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9_-]{0,63}$') + + +class DataBaseSettings(BaseSettings): + """ + 数据库集合配置 + """ + + model_config = SettingsConfigDict(hide_input_in_errors=True) + + db_default_source: str = 'primary' + db_sources: Annotated[dict[str, DataSourceSettings], NoDecode] = Field(default_factory=dict) + + @field_validator('db_sources', mode='before') + @classmethod + def parse_sources_json(cls, value: object) -> object: + """ + 解析显式传入的数据源JSON字符串 + + :param value: 数据源配置原始值 + :return: 解析后的数据源配置 + """ + if not isinstance(value, str): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + raise ValueError('DB_SOURCES JSON 格式错误') from None + + @model_validator(mode='after') + def validate_sources(self) -> 'DataBaseSettings': + """ + 校验数据源集合和默认数据源配置 + + :return: 数据库集合配置 + """ + if not self.db_sources: + raise ValueError('DB_SOURCES 不能为空') + if self.db_default_source not in self.db_sources: + raise ValueError(f'默认数据源不存在:{self.db_default_source}') + for name in self.db_sources: + if not DATA_SOURCE_NAME_PATTERN.fullmatch(name): + raise ValueError(f'数据源名称不合法:{name}') + return self + + def get_source(self, name: str | None = None) -> DataSourceSettings: + """ + 获取指定数据源配置 + + :param name: 数据源名称 + :return: 数据源配置 + """ + source_name = name or self.db_default_source + try: + return self.db_sources[source_name] + except KeyError as exc: + raise DataSourceNotFoundException(source_name) from exc + + @property + def default_source(self) -> DataSourceSettings: + """ + 获取默认数据源配置 + + :return: 默认数据源配置 + """ + return self.get_source() + + class RedisSettings(BaseSettings): """ Redis配置 diff --git a/ruoyi-fastapi-backend/config/get_redis.py b/ruoyi-fastapi-backend/config/get_redis.py index ef1e6ba..2b42461 100644 --- a/ruoyi-fastapi-backend/config/get_redis.py +++ b/ruoyi-fastapi-backend/config/get_redis.py @@ -3,7 +3,7 @@ from redis.exceptions import AuthenticationError, RedisError from redis.exceptions import TimeoutError as RedisTimeoutError -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from config.env import RedisConfig from module_admin.service.config_service import ConfigService from module_admin.service.dict_service import DictDataService @@ -98,7 +98,7 @@ async def init_sys_dict(cls, redis: FastAPI) -> None: :param redis: redis对象 :return: """ - async with AsyncSessionLocal() as session: + async with DataSourceRegistry.session() as session: await DictDataService.init_cache_sys_dict_services(session, redis) @classmethod @@ -109,5 +109,5 @@ async def init_sys_config(cls, redis: aioredis.Redis) -> None: :param redis: redis对象 :return: """ - async with AsyncSessionLocal() as session: + async with DataSourceRegistry.session() as session: await ConfigService.init_cache_sys_config_services(session, redis) diff --git a/ruoyi-fastapi-backend/config/get_scheduler.py b/ruoyi-fastapi-backend/config/get_scheduler.py index 8837c33..5190ee5 100644 --- a/ruoyi-fastapi-backend/config/get_scheduler.py +++ b/ruoyi-fastapi-backend/config/get_scheduler.py @@ -21,18 +21,12 @@ from apscheduler.util import obj_to_ref from redis import asyncio as aioredis from sqlalchemy.engine import Engine -from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import sessionmaker import module_task # noqa: F401 from common.constant import LockConstant -from config.database import ( - SYNC_SQLALCHEMY_DATABASE_URL, - create_async_db_engine, - create_async_session_local, - create_sync_db_engine, - create_sync_session_local, -) -from config.env import AppConfig, LogConfig, RedisConfig +from config.database import DataSourceRegistry, create_sync_db_engine +from config.env import AppConfig, DataBaseConfig, LogConfig, RedisConfig from module_admin.dao.job_dao import JobDao from module_admin.entity.vo.job_vo import JobLogModel, JobModel from module_admin.service.job_log_service import JobLogService @@ -134,8 +128,6 @@ class SchedulerUtil: _reacquire_interval_seconds: float = 5.0 _reacquire_jitter_seconds: float = 1.0 _is_closing: bool = False - _sync_async_engine: AsyncEngine | None = None - _sync_async_sessionmaker: Any | None = None _disposed_sync_engines: bool = False # 懒加载的同步 Engine 和 SessionLocal @@ -183,7 +175,8 @@ def _get_jobstore_engine(cls) -> Engine: :return: 同步 Engine """ if cls._jobstore_engine is None: - cls._jobstore_engine = create_sync_db_engine(echo=False) + # JobStore 使用独立 Engine,避免 APScheduler 关闭时释放 Registry 共享的 Engine。 + cls._jobstore_engine = create_sync_db_engine(echo=False, config=DataBaseConfig.get_source()) return cls._jobstore_engine @classmethod @@ -194,7 +187,7 @@ def _get_listener_engine(cls) -> Engine: :return: 同步 Engine """ if cls._listener_engine is None: - cls._listener_engine = create_sync_db_engine() + cls._listener_engine = DataSourceRegistry.get_sync_engine(DataBaseConfig.db_default_source) return cls._listener_engine @classmethod @@ -205,7 +198,11 @@ def _get_session_local(cls) -> Any: :return: SessionLocal """ if cls._session_local is None: - cls._session_local = create_sync_session_local(cls._get_listener_engine()) + cls._session_local = sessionmaker( + autocommit=False, + autoflush=False, + bind=cls._get_listener_engine(), + ) return cls._session_local @classmethod @@ -219,7 +216,7 @@ def _configure_scheduler(cls) -> None: return job_stores = { 'default': MemoryJobStore(), - 'sqlalchemy': SQLAlchemyJobStore(url=SYNC_SQLALCHEMY_DATABASE_URL, engine=cls._get_jobstore_engine()), + 'sqlalchemy': SQLAlchemyJobStore(engine=cls._get_jobstore_engine()), 'redis': RedisJobStore(**redis_config), } executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)} @@ -425,7 +422,7 @@ async def _handle_lock_lost(cls) -> None: cls._sync_pending = False if getattr(scheduler, 'running', False): scheduler.shutdown() - await cls._dispose_sync_async_engine() + cls._scheduler_configured = False cls._dispose_sync_engines() cls._ensure_reacquire_task() @@ -603,22 +600,8 @@ def _get_sync_async_session(cls) -> Any: :return: 异步 Session """ - if not cls._sync_async_sessionmaker: - cls._sync_async_engine = create_async_db_engine(echo=False) - cls._sync_async_sessionmaker = create_async_session_local(cls._sync_async_engine) - return cls._sync_async_sessionmaker() - - @classmethod - async def _dispose_sync_async_engine(cls) -> None: - """ - 释放同步任务使用的异步 Engine - - :return: None - """ - if cls._sync_async_engine: - await cls._sync_async_engine.dispose() - cls._sync_async_engine = None - cls._sync_async_sessionmaker = None + # 每次同步都从注册中心创建新的异步上下文,避免复用已经退出的会话上下文。 + return DataSourceRegistry.session(DataBaseConfig.db_default_source) @classmethod def _dispose_sync_engines(cls) -> None: @@ -632,9 +615,8 @@ def _dispose_sync_engines(cls) -> None: if cls._jobstore_engine: cls._jobstore_engine.dispose() cls._jobstore_engine = None - if cls._listener_engine: - cls._listener_engine.dispose() - cls._listener_engine = None + # Listener 使用 Registry 共享的 Engine,此处只清理引用,避免影响其他服务。 + cls._listener_engine = None cls._session_local = None cls._disposed_sync_engines = True @@ -927,7 +909,6 @@ async def close_system_scheduler(cls) -> None: except asyncio.CancelledError: pass cls._reacquire_task = None - await cls._dispose_sync_async_engine() cls._dispose_sync_engines() if cls._lock_lost_task: cls._lock_lost_task.cancel() @@ -939,6 +920,7 @@ async def close_system_scheduler(cls) -> None: if getattr(scheduler, 'running', False): scheduler.shutdown() logger.info('✅️ 关闭定时任务成功') + cls._scheduler_configured = False # 必须在Redis连接池关闭前,原子释放当前进程持有的Application leader租约 redis = cls._redis cls._redis = None diff --git a/ruoyi-fastapi-backend/config/get_db.py b/ruoyi-fastapi-backend/config/lifecycle.py similarity index 51% rename from ruoyi-fastapi-backend/config/get_db.py rename to ruoyi-fastapi-backend/config/lifecycle.py index 547b2bd..9bd8b1f 100644 --- a/ruoyi-fastapi-backend/config/get_db.py +++ b/ruoyi-fastapi-backend/config/lifecycle.py @@ -1,29 +1,16 @@ -from collections.abc import AsyncGenerator from typing import Literal -from sqlalchemy.ext.asyncio import AsyncSession - -from config.database import AsyncSessionLocal, Base, async_engine +from config.database import Base, DataSourceRegistry from utils.log_util import logger -async def get_db() -> AsyncGenerator[AsyncSession, None]: - """ - 每一个请求处理完毕后会关闭当前连接,不同的请求使用不同的连接 - - :return: - """ - async with AsyncSessionLocal() as current_db: - yield current_db - - async def init_create_table( *, stage: Literal['platform', 'plugin_entities'] = 'platform', log_success_enabled: bool = True, ) -> None: """ - 应用启动时初始化数据库元数据。 + 在默认数据源中初始化平台数据库元数据 :param stage: 建表阶段 :param log_success_enabled: 是否输出阶段成功摘要 @@ -32,17 +19,8 @@ async def init_create_table( if log_success_enabled: message = '🔎 初始化平台数据库元数据...' if stage == 'platform' else '🔎 同步插件实体表...' logger.bind(database_init_stage=stage).info(message) - async with async_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + async with DataSourceRegistry.connection() as connection: + await connection.run_sync(Base.metadata.create_all) if log_success_enabled: message = '✅️ 平台数据库元数据初始化完成' if stage == 'platform' else '✅️ 插件实体表同步完成' logger.bind(database_init_stage=stage).info(message) - - -async def close_async_engine() -> None: - """ - 应用关闭时释放数据库连接池 - - :return: - """ - await async_engine.dispose() diff --git a/ruoyi-fastapi-backend/exceptions/exception.py b/ruoyi-fastapi-backend/exceptions/exception.py index 519df0e..5fe5617 100644 --- a/ruoyi-fastapi-backend/exceptions/exception.py +++ b/ruoyi-fastapi-backend/exceptions/exception.py @@ -65,3 +65,73 @@ class ModelValidatorException(Exception): def __init__(self, data: str | None = None, message: str | None = None) -> None: self.data = data self.message = message + + +class DataSourceException(ServiceException): + """ + 自定义数据源异常DataSourceException + + 对外异常信息仅包含数据源名称,内部诊断字段仅保留异常类型和数字错误码。 + """ + + def __init__( + self, + source_name: str, + message: str | None = None, + *, + error_type: str | None = None, + error_code: int | None = None, + ) -> None: + self.source_name = source_name + self.error_type = error_type + self.error_code = error_code + super().__init__(message=message or f'数据源异常:{source_name}') + + +class DataSourceNotFoundException(DataSourceException): + """ + 自定义数据源未配置异常DataSourceNotFoundException + """ + + def __init__(self, source_name: str) -> None: + super().__init__(source_name, message=f'数据源未配置:{source_name}') + + +class DataSourceUnavailableException(DataSourceException): + """ + 自定义数据源不可用异常DataSourceUnavailableException + """ + + def __init__( + self, + source_name: str, + *, + error_type: str | None = None, + error_code: int | None = None, + ) -> None: + super().__init__( + source_name, + message=f'数据源暂不可用:{source_name}', + error_type=error_type, + error_code=error_code, + ) + + +class DataSourceInitializationException(DataSourceException): + """ + 自定义数据源初始化异常DataSourceInitializationException + """ + + def __init__( + self, + source_name: str, + *, + error_type: str | None = None, + error_code: int | None = None, + ) -> None: + super().__init__( + source_name, + message=f'数据源初始化失败:{source_name}', + error_type=error_type, + error_code=error_code, + ) diff --git a/ruoyi-fastapi-backend/module_admin/controller/common_controller.py b/ruoyi-fastapi-backend/module_admin/controller/common_controller.py index 3b165c8..52ddd23 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/common_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/common_controller.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiNamespace from common.router import APIRouterPro diff --git a/ruoyi-fastapi-backend/module_admin/controller/config_controller.py b/ruoyi-fastapi-backend/module_admin/controller/config_controller.py index bbe6cf2..29eb73f 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/config_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/config_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py index ebea1b4..6f00b9a 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/dict_controller.py b/ruoyi-fastapi-backend/module_admin/controller/dict_controller.py index cd72983..66d04fc 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/dict_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/dict_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/file_controller.py b/ruoyi-fastapi-backend/module_admin/controller/file_controller.py index f0870e7..942d2ec 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/file_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/file_controller.py @@ -9,7 +9,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/job_controller.py b/ruoyi-fastapi-backend/module_admin/controller/job_controller.py index 38db601..67d60b8 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/job_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/job_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/log_controller.py b/ruoyi-fastapi-backend/module_admin/controller/log_controller.py index e84d0f8..8eca2c2 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/log_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/log_controller.py @@ -6,7 +6,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import PreAuthDependency from common.constant import ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py index 73857bc..a3cdfc4 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/login_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/login_controller.py @@ -8,7 +8,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.pre_auth import CurrentUserDependency from common.constant import ApiGroup, ApiNamespace from common.enums import BusinessType, RedisInitKeyConfig diff --git a/ruoyi-fastapi-backend/module_admin/controller/menu_controller.py b/ruoyi-fastapi-backend/module_admin/controller/menu_controller.py index b0b9812..a8fb8c7 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/menu_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/menu_controller.py @@ -7,7 +7,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/notice_controller.py b/ruoyi-fastapi-backend/module_admin/controller/notice_controller.py index 7e6c276..667421f 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/notice_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/notice_controller.py @@ -7,7 +7,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/online_controller.py b/ruoyi-fastapi-backend/module_admin/controller/online_controller.py index ff152bb..2fe9102 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/online_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/online_controller.py @@ -5,7 +5,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import PreAuthDependency from common.constant import ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/post_controller.py b/ruoyi-fastapi-backend/module_admin/controller/post_controller.py index ff84f96..30d78b4 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/post_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/post_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/role_controller.py b/ruoyi-fastapi-backend/module_admin/controller/role_controller.py index c86dc00..120aab4 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/role_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/role_controller.py @@ -11,7 +11,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py index 070ffae..8f2c0c8 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py @@ -13,7 +13,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitBypassConfig, ApiRateLimitPreset from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/config_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/config_do.py index 0c40b8d..b10c7e0 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/config_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/config_do.py @@ -27,6 +27,6 @@ class SysConfig(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/dept_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/dept_do.py index c745b56..92d65de 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/dept_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/dept_do.py @@ -23,19 +23,19 @@ class SysDept(Base): leader = Column( String(20), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='负责人', ) phone = Column( String(11), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='联系电话', ) email = Column( String(50), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='邮箱', ) status = Column(CHAR(1), nullable=True, server_default='0', comment='部门状态(0正常 1停用)') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/dict_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/dict_do.py index 37b2b7b..0a84b8f 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/dict_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/dict_do.py @@ -26,7 +26,7 @@ class SysDictType(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) @@ -47,13 +47,13 @@ class SysDictData(Base): css_class = Column( String(100), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='样式属性(其他样式扩展)', ) list_class = Column( String(100), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='表格回显样式', ) is_default = Column(CHAR(1), nullable=True, server_default='N', comment='是否默认(Y是 N否)') @@ -65,6 +65,6 @@ class SysDictData(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/menu_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/menu_do.py index b7aa4c7..94d31e1 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/menu_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/menu_do.py @@ -23,13 +23,13 @@ class SysMenu(Base): component = Column( String(255), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='组件路径', ) query = Column( String(255), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='路由参数', ) route_name = Column(String(50), nullable=True, server_default="''", comment='路由名称') @@ -41,7 +41,7 @@ class SysMenu(Base): perms = Column( String(100), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='权限标识', ) icon = Column(String(100), nullable=True, server_default='#', comment='菜单图标') diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py index 00989e0..749004e 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/notice_do.py @@ -20,9 +20,9 @@ class SysNotice(Base): notice_title = Column(String(50), nullable=False, comment='公告标题') notice_type = Column(CHAR(1), nullable=False, comment='公告类型(1通知 2公告)') notice_content = Column( - mysql.LONGBLOB if DataBaseConfig.db_type == 'mysql' else LargeBinary, + mysql.LONGBLOB if DataBaseConfig.default_source.db_type == 'mysql' else LargeBinary, nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type, False), comment='公告内容', ) status = Column(CHAR(1), nullable=True, server_default='0', comment='公告状态(0正常 1关闭)') @@ -33,7 +33,7 @@ class SysNotice(Base): remark = Column( String(255), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/post_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/post_do.py index 5b18596..c42d057 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/post_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/post_do.py @@ -27,6 +27,6 @@ class SysPost(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/role_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/role_do.py index 39b0866..0d630cd 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/role_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/role_do.py @@ -27,13 +27,13 @@ class SysRole(Base): comment='数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)', ) menu_check_strictly = Column( - mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger, + mysql.TINYINT(display_width=1) if DataBaseConfig.default_source.db_type == 'mysql' else SmallInteger, nullable=True, server_default='1', comment='菜单树选择项是否关联显示', ) dept_check_strictly = Column( - mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger, + mysql.TINYINT(display_width=1) if DataBaseConfig.default_source.db_type == 'mysql' else SmallInteger, nullable=True, server_default='1', comment='部门树选择项是否关联显示', @@ -47,7 +47,7 @@ class SysRole(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py b/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py index 4d1cfe1..500e0b7 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py +++ b/ruoyi-fastapi-backend/module_admin/entity/do/user_do.py @@ -19,7 +19,7 @@ class SysUser(Base): dept_id = Column( BigInteger, nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type, False), comment='部门ID', ) user_name = Column(String(30), nullable=False, comment='用户账号') @@ -42,7 +42,7 @@ class SysUser(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_admin/service/file_service.py b/ruoyi-fastapi-backend/module_admin/service/file_service.py index 65bf281..d921cc7 100644 --- a/ruoyi-fastapi-backend/module_admin/service/file_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/file_service.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from config.env import UploadConfig from exceptions.exception import ServiceException from module_admin.dao.file_access_dao import FileAccessLogDao @@ -1158,7 +1158,7 @@ async def execute_reconcile_run_services(cls, run_id: str) -> None: :return: None """ try: - async with AsyncSessionLocal() as query_db: + async with DataSourceRegistry.session() as query_db: reconcile_run = await FileInfoDao.get_reconcile_run_by_id(query_db, run_id) if reconcile_run is None or reconcile_run.status != 'running': return @@ -1199,7 +1199,7 @@ async def execute_reconcile_run_services(cls, run_id: str) -> None: ) except Exception as exc: logger.exception(f'文件存储对账任务{run_id}执行失败') - async with AsyncSessionLocal() as query_db: + async with DataSourceRegistry.session() as query_db: try: await FileInfoDao.finish_reconcile_run( query_db, @@ -1221,7 +1221,7 @@ async def run_scheduled_reconcile_services(cls, check_hash: bool = False) -> Non :param check_hash: 是否校验文件摘要 :return: None """ - async with AsyncSessionLocal() as query_db: + async with DataSourceRegistry.session() as query_db: try: reconcile_run = await cls.start_reconcile_run_services( query_db, diff --git a/ruoyi-fastapi-backend/module_admin/service/log_service.py b/ruoyi-fastapi-backend/module_admin/service/log_service.py index fdb334b..f810918 100644 --- a/ruoyi-fastapi-backend/module_admin/service/log_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/log_service.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from config.env import LogConfig from exceptions.exception import ServiceException from middlewares.trace_middleware.ctx import TraceCtx @@ -491,7 +491,7 @@ async def _process_messages(cls, redis: aioredis.Redis, stream_name: str, messag """ if not messages: return - async with AsyncSessionLocal() as session: + async with DataSourceRegistry.session() as session: ack_ids: list[str] = [] dedup_event_ids: list[str] = [] try: diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index 5a0e52b..71c1116 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -8,12 +8,12 @@ from sqlalchemy import Row from sqlalchemy.ext.asyncio import AsyncSession +from common.aspect.db_session import DBSessionDependency from common.constant import CommonConstant, MenuConstant from common.context import RequestContext from common.enums import PasswordCharacterType, RedisInitKeyConfig from common.vo import CrudResponseModel from config.env import AppConfig, JwtConfig -from config.get_db import get_db from exceptions.exception import AuthException, LoginException, ServiceException from module_admin.dao.login_dao import login_by_account from module_admin.dao.user_dao import UserDao @@ -211,7 +211,10 @@ async def create_access_token(cls, data: dict, expires_delta: timedelta | None = @classmethod async def get_current_user( - cls, request: Request = Request, token: str = Depends(oauth2_scheme), query_db: AsyncSession = Depends(get_db) + cls, + request: Request = Request, + token: str = Depends(oauth2_scheme), + query_db: AsyncSession = DBSessionDependency(), ) -> CurrentUserModel: """ 根据token获取当前用户信息 diff --git a/ruoyi-fastapi-backend/module_generator/controller/gen_controller.py b/ruoyi-fastapi-backend/module_generator/controller/gen_controller.py index a62e82c..e4e45ed 100644 --- a/ruoyi-fastapi-backend/module_generator/controller/gen_controller.py +++ b/ruoyi-fastapi-backend/module_generator/controller/gen_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitBypassConfig, ApiRateLimitPreset -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import RoleInterfaceAuthDependency, UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace @@ -21,6 +21,7 @@ from module_generator.entity.vo.gen_vo import ( DeleteGenTableModel, EditGenTableModel, + GenDataSourceModel, GenTableDbRowModel, GenTableDetailModel, GenTablePageQueryModel, @@ -34,6 +35,16 @@ gen_controller = APIRouterPro(prefix='/tool/gen', order_num=17, tags=['代码生成'], dependencies=[PreAuthDependency()]) +@gen_controller.get( + '/dataSources', + summary='获取代码生成数据源选项接口', + response_model=DataResponseModel[list[GenDataSourceModel]], + dependencies=[UserInterfaceAuthDependency('tool:gen:list')], +) +async def get_gen_data_sources(request: Request) -> Response: + return ResponseUtil.success(data=GenTableService.get_data_source_list_services()) + + @gen_controller.get( '/list', summary='获取代码生成表分页列表接口', @@ -93,9 +104,12 @@ async def import_gen_table( tables: Annotated[str, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + source_name: Annotated[str | None, Query(alias='dataSourceName')] = None, ) -> Response: table_names = tables.split(',') if tables else [] - add_gen_table_list = await GenTableService.get_gen_db_table_list_by_name_services(query_db, table_names) + add_gen_table_list = await GenTableService.get_gen_db_table_list_by_name_services( + query_db, table_names, source_name + ) add_gen_table_result = await GenTableService.import_gen_table_services(query_db, add_gen_table_list, current_user) logger.info(add_gen_table_result.message) @@ -167,8 +181,9 @@ async def create_table( sql: Annotated[str, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + source_name: Annotated[str | None, Query(alias='dataSourceName')] = None, ) -> Response: - create_table_result = await GenTableService.create_table_services(query_db, sql, current_user) + create_table_result = await GenTableService.create_table_services(query_db, sql, current_user, source_name) logger.info(create_table_result.message) return ResponseUtil.success(msg=create_table_result.message) @@ -199,9 +214,10 @@ async def batch_gen_code( request: Request, tables: Annotated[str, Query()], query_db: Annotated[AsyncSession, DBSessionDependency()], + source_name: Annotated[str | None, Query(alias='dataSourceName')] = None, ) -> Response: table_names = tables.split(',') if tables else [] - batch_gen_code_result = await GenTableService.batch_gen_code_services(query_db, table_names) + batch_gen_code_result = await GenTableService.batch_gen_code_services(query_db, table_names, source_name) logger.info('生成代码成功') return ResponseUtil.streaming(data=bytes2file_response(batch_gen_code_result)) @@ -225,11 +241,12 @@ async def gen_code_local( request: Request, table_name: Annotated[str, Path(description='表名称')], query_db: Annotated[AsyncSession, DBSessionDependency()], + source_name: Annotated[str | None, Query(alias='dataSourceName')] = None, ) -> Response: if not GenConfig.allow_overwrite: logger.error('【系统预设】不允许生成文件覆盖到本地') return ResponseUtil.error('【系统预设】不允许生成文件覆盖到本地') - gen_code_local_result = await GenTableService.generate_code_services(query_db, table_name) + gen_code_local_result = await GenTableService.generate_code_services(query_db, table_name, source_name) logger.info(gen_code_local_result.message) return ResponseUtil.success(msg=gen_code_local_result.message) @@ -249,7 +266,7 @@ async def query_detail_gen_table( query_db: Annotated[AsyncSession, DBSessionDependency()], ) -> Response: gen_table = await GenTableService.get_gen_table_by_id_services(query_db, table_id) - gen_tables = await GenTableService.get_gen_table_all_services(query_db) + gen_tables = await GenTableService.get_gen_table_all_services(query_db, gen_table.data_source_name) gen_columns = await GenTableColumnService.get_gen_table_column_list_by_table_id_services(query_db, table_id) gen_table_detail_result = {'info': gen_table, 'rows': gen_columns, 'tables': gen_tables} logger.info(f'获取table_id为{table_id}的信息成功') @@ -290,8 +307,9 @@ async def sync_db( request: Request, table_name: Annotated[str, Path(description='表名称')], query_db: Annotated[AsyncSession, DBSessionDependency()], + source_name: Annotated[str | None, Query(alias='dataSourceName')] = None, ) -> Response: - sync_db_result = await GenTableService.sync_db_services(query_db, table_name) + sync_db_result = await GenTableService.sync_db_services(query_db, table_name, source_name) logger.info(sync_db_result.message) return ResponseUtil.success(data=sync_db_result.message) diff --git a/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py b/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py index a855063..7c0dd70 100644 --- a/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py +++ b/ruoyi-fastapi-backend/module_generator/dao/gen_dao.py @@ -1,14 +1,15 @@ from collections.abc import Sequence +from dataclasses import dataclass from datetime import datetime, time from typing import Any -from sqlalchemy import Row, delete, func, select, text, update +from sqlalchemy import Row, bindparam, delete, func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlglot.expressions import Expression from common.vo import PageModel -from config.env import DataBaseConfig +from config.env import DataBaseConfig, DataSourceSettings from module_generator.entity.do.gen_do import GenTable, GenTableColumn from module_generator.entity.vo.gen_vo import ( GenTableBaseModel, @@ -20,6 +21,117 @@ from utils.page_util import PageUtil +@dataclass(frozen=True, slots=True) +class DatabaseMetadataAdapter: + """代码生成器使用的数据库元数据查询。""" + + table_list_query: str + tables_by_name_query: str + columns_query: str + created_after_filter: str + created_before_filter: str + + +_METADATA_ADAPTERS = { + 'mysql': DatabaseMetadataAdapter( + table_list_query=r""" + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + information_schema.tables + where + table_schema = (select database()) + and table_name not like 'apscheduler\_%' + and table_name not like 'gen\_%' + """, + tables_by_name_query=r""" + select + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + information_schema.tables + where + table_name not like 'qrtz\_%' + and table_name not like 'gen\_%' + and table_schema = (select database()) + and table_name in :table_names + """, + columns_query=""" + select + column_name as column_name, + case when is_nullable = 'no' and column_key != 'PRI' then '1' else '0' end as is_required, + case when column_key = 'PRI' then '1' else '0' end as is_pk, + ordinal_position as sort, + column_comment as column_comment, + case when extra = 'auto_increment' then '1' else '0' end as is_increment, + column_type as column_type + from + information_schema.columns + where + table_schema = (select database()) + and table_name = :table_name + order by + ordinal_position + """, + created_after_filter=" and date_format(create_time, '%Y%m%d') >= date_format(:begin_time, '%Y%m%d')", + created_before_filter=" and date_format(create_time, '%Y%m%d') <= date_format(:end_time, '%Y%m%d')", + ), + 'postgresql': DatabaseMetadataAdapter( + table_list_query=""" + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + list_table + where + table_name not like 'apscheduler_%' + and table_name not like 'gen_%' + """, + tables_by_name_query=""" + select + table_name as table_name, + table_comment as table_comment, + create_time as create_time, + update_time as update_time + from + list_table + where + table_name not like 'qrtz_%' + and table_name not like 'gen_%' + and table_name in :table_names + """, + columns_query=""" + select + column_name, is_required, is_pk, sort, column_comment, is_increment, column_type + from + list_column + where + table_name = :table_name + """, + created_after_filter=" and create_time::date >= to_date(:begin_time, 'yyyy-MM-dd')", + created_before_filter=" and create_time::date <= to_date(:end_time, 'yyyy-MM-dd')", + ), +} + + +def _get_database_metadata_adapter(db_type: str) -> DatabaseMetadataAdapter: + """ + 根据数据库类型获取元数据查询适配器 + + :param db_type: 数据库类型 + :return: 元数据查询适配器 + """ + try: + return _METADATA_ADAPTERS[db_type] + except KeyError as exc: + raise ValueError(f'不支持的数据库类型:{db_type!r}') from exc + + class GenTableDao: """ 代码生成业务表模块数据库操作层 @@ -47,18 +159,24 @@ async def get_gen_table_by_id(cls, db: AsyncSession, table_id: int) -> GenTable return gen_table_info @classmethod - async def get_gen_table_by_name(cls, db: AsyncSession, table_name: str) -> GenTable | None: + async def get_gen_table_by_name(cls, db: AsyncSession, table_name: str, source_name: str) -> GenTable | None: """ 根据业务表名称获取需要生成的业务表信息 :param db: orm对象 :param table_name: 业务表名称 + :param source_name: 数据源名称 :return: 需要生成的业务表信息对象 """ gen_table_info = ( ( await db.execute( - select(GenTable).options(selectinload(GenTable.columns)).where(GenTable.table_name == table_name) + select(GenTable) + .options(selectinload(GenTable.columns)) + .where( + GenTable.table_name == table_name, + GenTable.data_source_name == source_name, + ) ) ) .scalars() @@ -68,28 +186,35 @@ async def get_gen_table_by_name(cls, db: AsyncSession, table_name: str) -> GenTa return gen_table_info @classmethod - async def get_gen_table_all(cls, db: AsyncSession) -> Sequence[GenTable]: + async def get_gen_table_all(cls, db: AsyncSession, source_name: str | None = None) -> Sequence[GenTable]: """ 获取所有业务表信息 :param db: orm对象 + :param source_name: 数据源名称 :return: 所有业务表信息 """ - gen_table_all = (await db.execute(select(GenTable).options(selectinload(GenTable.columns)))).scalars().all() + query = select(GenTable).options(selectinload(GenTable.columns)) + if source_name: + query = query.where(GenTable.data_source_name == source_name) + gen_table_all = (await db.execute(query)).scalars().all() return gen_table_all @classmethod - async def create_table_by_sql_dao(cls, db: AsyncSession, sql_statements: list[Expression]) -> None: + async def create_table_by_sql_dao( + cls, db: AsyncSession, sql_statements: list[Expression], *, source_config: DataSourceSettings + ) -> None: """ 根据sql语句创建表结构 :param db: orm对象 :param sql_statements: sql语句的ast列表 + :param source_config: 目标数据源配置 :return: """ for sql_statement in sql_statements: - sql = sql_statement.sql(dialect=DataBaseConfig.sqlglot_parse_dialect) + sql = sql_statement.sql(dialect=source_config.sqlglot_parse_dialect) await db.execute(text(sql)) @classmethod @@ -120,6 +245,7 @@ async def get_gen_table_list( ) if query_object.begin_time and query_object.end_time else True, + GenTable.data_source_name == query_object.data_source_name if query_object.data_source_name else True, ) .distinct() ) @@ -129,9 +255,29 @@ async def get_gen_table_list( return gen_table_list + @classmethod + async def get_gen_table_names(cls, db: AsyncSession, source_name: str | None = None) -> set[str]: + """ + 获取控制库中指定数据源已导入的业务表名称 + + :param db: orm对象 + :param source_name: 数据源名称 + :return: 已导入的业务表名称集合 + """ + query = select(GenTable.table_name) + if source_name: + query = query.where(GenTable.data_source_name == source_name) + return {name for name in (await db.execute(query)).scalars().all() if name} + @classmethod async def get_gen_db_table_list( - cls, db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + cls, + db: AsyncSession, + query_object: GenTablePageQueryModel, + is_page: bool = False, + *, + excluded_table_names: set[str] | None = None, + source_config: DataSourceSettings | None = None, ) -> PageModel | list[dict[str, Any]]: """ 根据查询参数获取数据库列表信息 @@ -139,56 +285,34 @@ async def get_gen_db_table_list( :param db: orm对象 :param query_object: 查询参数对象 :param is_page: 是否开启分页 + :param excluded_table_names: 需要排除的已导入表名称集合 + :param source_config: 目标数据源配置 :return: 数据库列表信息对象 """ - query_params: dict[str, str] = {} - if DataBaseConfig.db_type == 'postgresql': - query_sql = """ - table_name as table_name, - table_comment as table_comment, - create_time as create_time, - update_time as update_time - from - list_table - where - table_name not like 'apscheduler_%' - and table_name not like 'gen_%' - and table_name not in (select table_name from gen_table) - """ - else: - query_sql = r""" - table_name as table_name, - table_comment as table_comment, - create_time as create_time, - update_time as update_time - from - information_schema.tables - where - table_schema = (select database()) - and table_name not like 'apscheduler\_%' - and table_name not like 'gen\_%' - and table_name not in (select table_name from gen_table) - """ + source_config = source_config or DataBaseConfig.default_source + metadata = _get_database_metadata_adapter(source_config.db_type) + query_params: dict[str, Any] = {} + query_sql = metadata.table_list_query + if excluded_table_names: + query_sql += ' and table_name not in :excluded_table_names' + query_params['excluded_table_names'] = tuple(excluded_table_names) if query_object.table_name: - query_sql += """ and lower(table_name) like lower(concat('%', :table_name, '%'))""" + query_sql += " and lower(table_name) like lower(concat('%', :table_name, '%'))" query_params['table_name'] = query_object.table_name if query_object.table_comment: - query_sql += """ and lower(table_comment) like lower(concat('%', :table_comment, '%'))""" + query_sql += " and lower(table_comment) like lower(concat('%', :table_comment, '%'))" query_params['table_comment'] = query_object.table_comment if query_object.begin_time: - if DataBaseConfig.db_type == 'postgresql': - query_sql += """ and create_time::date >= to_date(:begin_time, 'yyyy-MM-dd')""" - else: - query_sql += """ and date_format(create_time, '%Y%m%d') >= date_format(:begin_time, '%Y%m%d')""" + query_sql += metadata.created_after_filter query_params['begin_time'] = query_object.begin_time if query_object.end_time: - if DataBaseConfig.db_type == 'postgresql': - query_sql += """ and create_time::date <= to_date(:end_time, 'yyyy-MM-dd')""" - else: - query_sql += """ and date_format(create_time, '%Y%m%d') <= date_format(:end_time, '%Y%m%d')""" + query_sql += metadata.created_before_filter query_params['end_time'] = query_object.end_time - query_sql += """ order by create_time desc""" - query = select(text(query_sql).bindparams(**query_params)) + query_sql += ' order by create_time desc' + statement = text(query_sql) + if excluded_table_names: + statement = statement.bindparams(bindparam('excluded_table_names', expanding=True)) + query = select(statement.bindparams(**query_params)) gen_db_table_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( db, query, query_object.page_num, query_object.page_size, is_page ) @@ -196,44 +320,20 @@ async def get_gen_db_table_list( return gen_db_table_list @classmethod - async def get_gen_db_table_list_by_names(cls, db: AsyncSession, table_names: list[str]) -> Sequence[Row]: + async def get_gen_db_table_list_by_names( + cls, db: AsyncSession, table_names: list[str], source_config: DataSourceSettings | None = None + ) -> Sequence[Row]: """ 根据业务表名称组获取数据库列表信息 :param db: orm对象 :param table_names: 业务表名称组 + :param source_config: 目标数据源配置 :return: 数据库列表信息对象 """ - if DataBaseConfig.db_type == 'postgresql': - query_sql = """ - select - table_name as table_name, - table_comment as table_comment, - create_time as create_time, - update_time as update_time - from - list_table - where - table_name not like 'qrtz_%' - and table_name not like 'gen_%' - and table_name = any(:table_names) - """ - else: - query_sql = r""" - select - table_name as table_name, - table_comment as table_comment, - create_time as create_time, - update_time as update_time - from - information_schema.tables - where - table_name not like 'qrtz\_%' - and table_name not like 'gen\_%' - and table_schema = (select database()) - and table_name in :table_names - """ - query = text(query_sql).bindparams(table_names=tuple(table_names)) + source_config = source_config or DataBaseConfig.default_source + query_sql = _get_database_metadata_adapter(source_config.db_type).tables_by_name_query + query = text(query_sql).bindparams(bindparam('table_names', value=table_names, expanding=True)) gen_db_table_list = (await db.execute(query)).fetchall() return gen_db_table_list @@ -303,50 +403,19 @@ async def get_gen_table_column_list_by_table_id(cls, db: AsyncSession, table_id: return gen_table_column_list @classmethod - async def get_gen_db_table_columns_by_name(cls, db: AsyncSession, table_name: str) -> Sequence[Row]: + async def get_gen_db_table_columns_by_name( + cls, db: AsyncSession, table_name: str, source_config: DataSourceSettings | None = None + ) -> Sequence[Row]: """ 根据业务表名称获取业务表字段列表信息 :param db: orm对象 :param table_name: 业务表名称 + :param source_config: 目标数据源配置 :return: 业务表字段列表信息对象 """ - if DataBaseConfig.db_type == 'postgresql': - query_sql = """ - select - column_name, is_required, is_pk, sort, column_comment, is_increment, column_type - from - list_column - where - table_name = :table_name - """ - else: - query_sql = """ - select - column_name as column_name, - case - when is_nullable = 'no' and column_key != 'PRI' then '1' - else '0' - end as is_required, - case - when column_key = 'PRI' then '1' - else '0' - end as is_pk, - ordinal_position as sort, - column_comment as column_comment, - case - when extra = 'auto_increment' then '1' - else '0' - end as is_increment, - column_type as column_type - from - information_schema.columns - where - table_schema = (select database()) - and table_name = :table_name - order by - ordinal_position - """ + source_config = source_config or DataBaseConfig.default_source + query_sql = _get_database_metadata_adapter(source_config.db_type).columns_query query = text(query_sql).bindparams(table_name=table_name) gen_db_table_columns = (await db.execute(query)).fetchall() diff --git a/ruoyi-fastapi-backend/module_generator/entity/do/gen_do.py b/ruoyi-fastapi-backend/module_generator/entity/do/gen_do.py index f2163b6..a5b386b 100644 --- a/ruoyi-fastapi-backend/module_generator/entity/do/gen_do.py +++ b/ruoyi-fastapi-backend/module_generator/entity/do/gen_do.py @@ -19,16 +19,17 @@ class GenTable(Base): table_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='编号') table_name = Column(String(200), nullable=True, server_default="''", comment='表名称') table_comment = Column(String(500), nullable=True, server_default="''", comment='表描述') + data_source_name = Column(String(64), nullable=False, server_default='primary', comment='目标数据源名称') sub_table_name = Column( String(64), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='关联子表的表名', ) sub_table_fk_name = Column( String(64), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='子表关联的外键名', ) class_name = Column(String(100), nullable=True, server_default="''", comment='实体类名称') @@ -54,7 +55,7 @@ class GenTable(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py b/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py index 4d30044..9486dcf 100644 --- a/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py +++ b/ruoyi-fastapi-backend/module_generator/entity/vo/gen_vo.py @@ -19,6 +19,7 @@ class GenTableBaseModel(BaseModel): table_id: int | None = Field(default=None, description='编号') table_name: str | None = Field(default=None, description='表名称') table_comment: str | None = Field(default=None, description='表描述') + data_source_name: str | None = Field(default=None, description='目标数据源名称') sub_table_name: str | None = Field(default=None, description='关联子表的表名') sub_table_fk_name: str | None = Field(default=None, description='子表关联的外键名') class_name: str | None = Field(default=None, description='实体类名称') @@ -103,6 +104,18 @@ class GenTableDbRowModel(BaseModel): update_time: datetime | None = Field(default=None, description='更新时间') +class GenDataSourceModel(BaseModel): + """ + 代码生成数据源选项模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + name: str + db_type: str = Field(description='数据库类型') + is_default: bool = False + + class GenTableModel(GenTableBaseModel): """ 代码生成业务表模型 diff --git a/ruoyi-fastapi-backend/module_generator/service/gen_service.py b/ruoyi-fastapi-backend/module_generator/service/gen_service.py index e9ec609..f9436cf 100644 --- a/ruoyi-fastapi-backend/module_generator/service/gen_service.py +++ b/ruoyi-fastapi-backend/module_generator/service/gen_service.py @@ -12,6 +12,7 @@ from common.constant import GenConstant from common.vo import CrudResponseModel, PageModel +from config.database import DataSourceRegistry from config.env import DataBaseConfig, GenConfig from exceptions.exception import ServiceException from module_admin.entity.vo.user_vo import CurrentUserModel @@ -19,6 +20,7 @@ from module_generator.entity.vo.gen_vo import ( DeleteGenTableModel, EditGenTableModel, + GenDataSourceModel, GenTableColumnModel, GenTableModel, GenTablePageQueryModel, @@ -33,6 +35,29 @@ class GenTableService: 代码生成业务表服务层 """ + @classmethod + def get_data_source_list_services(cls) -> list[GenDataSourceModel]: + """ + 获取代码生成可用的数据源列表 + + :return: 数据源选项列表 + """ + default_name = DataBaseConfig.db_default_source + return [ + GenDataSourceModel(name=name, dbType=config.db_type, isDefault=name == default_name) + for name, config in DataBaseConfig.db_sources.items() + ] + + @staticmethod + def _source_name(source_name: str | None) -> str: + """ + 获取代码生成操作使用的数据源名称 + + :param source_name: 数据源名称 + :return: 指定的数据源名称或默认数据源名称 + """ + return source_name or DataBaseConfig.db_default_source + @classmethod async def get_gen_table_list_services( cls, query_db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False @@ -51,7 +76,10 @@ async def get_gen_table_list_services( @classmethod async def get_gen_db_table_list_services( - cls, query_db: AsyncSession, query_object: GenTablePageQueryModel, is_page: bool = False + cls, + query_db: AsyncSession, + query_object: GenTablePageQueryModel, + is_page: bool = False, ) -> PageModel | list[dict[str, Any]]: """ 获取数据库列表信息service @@ -61,28 +89,47 @@ async def get_gen_db_table_list_services( :param is_page: 是否开启分页 :return: 数据库列表信息对象 """ - gen_db_table_list_result = await GenTableDao.get_gen_db_table_list(query_db, query_object, is_page) - - return gen_db_table_list_result + source_name = cls._source_name(query_object.data_source_name) + source_config = DataBaseConfig.get_source(source_name) + excluded = await GenTableDao.get_gen_table_names(query_db, source_name) + async with DataSourceRegistry.session(source_name) as target_db: + gen_db_table_list_result = await GenTableDao.get_gen_db_table_list( + target_db, + query_object, + is_page, + excluded_table_names=excluded, + source_config=source_config, + ) + return gen_db_table_list_result @classmethod async def get_gen_db_table_list_by_name_services( - cls, query_db: AsyncSession, table_names: list[str] + cls, query_db: AsyncSession, table_names: list[str], source_name: str | None = None ) -> list[GenTableModel]: """ 根据表名称组获取数据库列表信息service :param query_db: orm对象 :param table_names: 表名称组 + :param source_name: 数据源名称 :return: 数据库列表信息对象 """ - gen_db_table_list_result = await GenTableDao.get_gen_db_table_list_by_names(query_db, table_names) - - return [GenTableModel(**gen_table) for gen_table in CamelCaseUtil.transform_result(gen_db_table_list_result)] + source_name = cls._source_name(source_name) + source_config = DataBaseConfig.get_source(source_name) + async with DataSourceRegistry.session(source_name) as target_db: + rows = await GenTableDao.get_gen_db_table_list_by_names(target_db, table_names, source_config) + result = [ + GenTableModel(**gen_table, dataSourceName=source_name) + for gen_table in CamelCaseUtil.transform_result(rows) + ] + return result @classmethod async def import_gen_table_services( - cls, query_db: AsyncSession, gen_table_list: list[GenTableModel], current_user: CurrentUserModel + cls, + query_db: AsyncSession, + gen_table_list: list[GenTableModel], + current_user: CurrentUserModel, ) -> CrudResponseModel: """ 导入表结构service @@ -92,20 +139,26 @@ async def import_gen_table_services( :param current_user: 当前用户信息对象 :return: 导入结果 """ + source_name = cls._source_name(gen_table_list[0].data_source_name if gen_table_list else None) + source_config = DataBaseConfig.get_source(source_name) try: - for table in gen_table_list: - table_name = table.table_name - GenUtils.init_table(table, current_user.user.user_name) - add_gen_table = await GenTableDao.add_gen_table_dao(query_db, table) - if add_gen_table: - table.table_id = add_gen_table.table_id - gen_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name(query_db, table_name) - for column in [ - GenTableColumnModel(**gen_table_column) - for gen_table_column in CamelCaseUtil.transform_result(gen_table_columns) - ]: - GenUtils.init_column_field(column, table) - await GenTableColumnDao.add_gen_table_column_dao(query_db, column) + async with DataSourceRegistry.session(source_name) as target_db: + for table in gen_table_list: + table_name = table.table_name + table.data_source_name = source_name + GenUtils.init_table(table, current_user.user.user_name) + add_gen_table = await GenTableDao.add_gen_table_dao(query_db, table) + if add_gen_table: + table.table_id = add_gen_table.table_id + gen_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name( + target_db, table_name, source_config + ) + for column in [ + GenTableColumnModel(**gen_table_column) + for gen_table_column in CamelCaseUtil.transform_result(gen_table_columns) + ]: + GenUtils.init_column_field(column, table) + await GenTableColumnDao.add_gen_table_column_dao(query_db, column) await query_db.commit() return CrudResponseModel(is_success=True, message='导入成功') except Exception as e: @@ -125,6 +178,8 @@ async def edit_gen_table_services(cls, query_db: AsyncSession, page_object: Edit gen_table_info = await cls.get_gen_table_by_id_services(query_db, page_object.table_id) if gen_table_info.table_id: try: + # 数据源归属在导入时确定,编辑生成配置时不得隐式迁移到其他数据库。 + edit_gen_table['data_source_name'] = gen_table_info.data_source_name edit_gen_table['options'] = json.dumps(edit_gen_table.get('params')) await GenTableDao.edit_gen_table_dao(query_db, edit_gen_table) for gen_table_column in page_object.columns: @@ -183,21 +238,24 @@ async def get_gen_table_by_id_services(cls, query_db: AsyncSession, table_id: in return result @classmethod - async def get_gen_table_all_services(cls, query_db: AsyncSession) -> list[GenTableModel]: + async def get_gen_table_all_services( + cls, query_db: AsyncSession, source_name: str | None = None + ) -> list[GenTableModel]: """ 获取所有业务表信息service :param query_db: orm对象 + :param source_name: 数据源名称 :return: 所有业务表信息 """ - gen_table_all = await GenTableDao.get_gen_table_all(query_db) + gen_table_all = await GenTableDao.get_gen_table_all(query_db, source_name) result = [GenTableModel(**gen_table) for gen_table in CamelCaseUtil.transform_result(gen_table_all)] return result @classmethod async def create_table_services( - cls, query_db: AsyncSession, sql: str, current_user: CurrentUserModel + cls, query_db: AsyncSession, sql: str, current_user: CurrentUserModel, source_name: str | None = None ) -> CrudResponseModel: """ 创建表结构service @@ -205,14 +263,19 @@ async def create_table_services( :param query_db: orm对象 :param sql: 建表语句 :param current_user: 当前用户信息对象 + :param source_name: 数据源名称 :return: 创建表结构结果 """ - sql_statements = sqlglot_parse(sql, dialect=DataBaseConfig.sqlglot_parse_dialect) + source_name = cls._source_name(source_name) + target_config = DataBaseConfig.get_source(source_name) + sql_statements = sqlglot_parse(sql, dialect=target_config.sqlglot_parse_dialect) if cls.__is_valid_create_table(sql_statements): try: table_names = cls.__get_table_names(sql_statements) - await GenTableDao.create_table_by_sql_dao(query_db, sql_statements) - gen_table_list = await cls.get_gen_db_table_list_by_name_services(query_db, table_names) + async with DataSourceRegistry.session(source_name) as target_db: + await GenTableDao.create_table_by_sql_dao(target_db, sql_statements, source_config=target_config) + await target_db.commit() + gen_table_list = await cls.get_gen_db_table_list_by_name_services(query_db, table_names, source_name) await cls.import_gen_table_services(query_db, gen_table_list, current_user) return CrudResponseModel(is_success=True, message='创建表结构成功') @@ -277,16 +340,20 @@ async def preview_code_services(cls, query_db: AsyncSession, table_id: int) -> d return preview_code_result @classmethod - async def generate_code_services(cls, query_db: AsyncSession, table_name: str) -> CrudResponseModel: + async def generate_code_services( + cls, query_db: AsyncSession, table_name: str, source_name: str | None = None + ) -> CrudResponseModel: """ 生成代码至指定路径service :param query_db: orm对象 :param table_name: 业务表名称 + :param source_name: 数据源名称 :return: 生成代码结果 """ + source_name = cls._source_name(source_name) env = TemplateInitializer.init_jinja2() - render_info = await cls.__get_gen_render_info(query_db, table_name) + render_info = await cls.__get_gen_render_info(query_db, table_name, source_name) try: for template in render_info[0]: render_content = env.get_template(template).render(**render_info[2]) @@ -300,19 +367,30 @@ async def generate_code_services(cls, query_db: AsyncSession, table_name: str) - return CrudResponseModel(is_success=True, message='生成代码成功') @classmethod - async def batch_gen_code_services(cls, query_db: AsyncSession, table_names: list[str]) -> bytes: + async def batch_gen_code_services( + cls, query_db: AsyncSession, table_names: list[str], source_name: str | None = None + ) -> bytes: """ 批量生成代码service :param query_db: orm对象 :param table_names: 业务表名称组 + :param source_name: 数据源名称 :return: 下载代码结果 """ + source_name = cls._source_name(source_name) + configured_table_names = await GenTableDao.get_gen_table_names(query_db, source_name) + invalid_table_names = list(dict.fromkeys(name for name in table_names if name not in configured_table_names)) + if invalid_table_names: + raise ServiceException( + message=f'业务表不存在或不属于数据源 {source_name}:{", ".join(invalid_table_names)}' + ) + zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: for table_name in table_names: env = TemplateInitializer.init_jinja2() - render_info = await cls.__get_gen_render_info(query_db, table_name) + render_info = await cls.__get_gen_render_info(query_db, table_name, source_name) for template_file, output_file in zip(render_info[0], render_info[1], strict=False): render_content = env.get_template(template_file).render(**render_info[2]) zip_file.writestr(output_file, render_content) @@ -322,17 +400,21 @@ async def batch_gen_code_services(cls, query_db: AsyncSession, table_names: list return zip_data @classmethod - async def __get_gen_render_info(cls, query_db: AsyncSession, table_name: str) -> list: + async def __get_gen_render_info( + cls, query_db: AsyncSession, table_name: str, source_name: str | None = None + ) -> list: """ 获取生成代码渲染模板相关信息 :param query_db: orm对象 :param table_name: 业务表名称 + :param source_name: 数据源名称 :return: 生成代码渲染模板相关信息 """ - gen_table = GenTableModel( - **CamelCaseUtil.transform_result(await GenTableDao.get_gen_table_by_name(query_db, table_name)) - ) + gen_table_info = await GenTableDao.get_gen_table_by_name(query_db, table_name, source_name) + if gen_table_info is None: + raise ServiceException(message=f'业务表不存在或不属于数据源 {source_name}:{table_name}') + gen_table = GenTableModel(**CamelCaseUtil.transform_result(gen_table_info)) await cls.set_sub_table(query_db, gen_table) await cls.set_pk_column(gen_table) context = TemplateUtils.prepare_context(gen_table) @@ -357,22 +439,30 @@ def __get_gen_path(cls, gen_table: GenTableModel, template: str) -> str: return os.path.join(gen_path, TemplateUtils.get_file_name(template, gen_table)) @classmethod - async def sync_db_services(cls, query_db: AsyncSession, table_name: str) -> CrudResponseModel: + async def sync_db_services( + cls, query_db: AsyncSession, table_name: str, source_name: str | None = None + ) -> CrudResponseModel: """ 同步数据库service :param query_db: orm对象 :param table_name: 业务表名称 + :param source_name: 数据源名称 :return: 同步数据库结果 """ - gen_table = await GenTableDao.get_gen_table_by_name(query_db, table_name) + source_name = cls._source_name(source_name) + gen_table = await GenTableDao.get_gen_table_by_name(query_db, table_name, source_name) table = GenTableModel(**CamelCaseUtil.transform_result(gen_table)) table_columns = table.columns table_column_map = {column.column_name: column for column in table_columns} - query_db_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name(query_db, table_name) - db_table_columns = [ - GenTableColumnModel(**column) for column in CamelCaseUtil.transform_result(query_db_table_columns) - ] + source_config = DataBaseConfig.get_source(source_name) + async with DataSourceRegistry.session(source_name) as target_db: + query_db_table_columns = await GenTableColumnDao.get_gen_db_table_columns_by_name( + target_db, table_name, source_config + ) + db_table_columns = [ + GenTableColumnModel(**column) for column in CamelCaseUtil.transform_result(query_db_table_columns) + ] if not db_table_columns: raise ServiceException('同步数据失败,原表结构不存在') db_table_column_names = [column.column_name for column in db_table_columns] @@ -416,7 +506,11 @@ async def set_sub_table(cls, query_db: AsyncSession, gen_table: GenTableModel) - :return: """ if gen_table.sub_table_name: - sub_table = await GenTableDao.get_gen_table_by_name(query_db, gen_table.sub_table_name) + sub_table = await GenTableDao.get_gen_table_by_name( + query_db, + gen_table.sub_table_name, + cls._source_name(gen_table.data_source_name), + ) gen_table.sub_table = GenTableModel(**CamelCaseUtil.transform_result(sub_table)) @classmethod diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 index b4340ea..11aec67 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/controller.py.jinja2 @@ -19,7 +19,7 @@ from pydantic_validation_decorator import ValidateFields from sqlalchemy.ext.asyncio import AsyncSession from common.annotation.log_annotation import Log -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.enums import BusinessType @@ -48,7 +48,7 @@ from utils.response_util import ResponseUtil async def get_{{ moduleName }}_{{ businessName }}_list( request: Request, {% if table.crud or table.sub %}{{ businessName }}_page_query{% elif table.tree %}{{ businessName }}_query{% endif %}: Annotated[{{ BusinessName }}PageQueryModel, Query()], - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], ) -> Response: {% if table.crud or table.sub %} # 获取分页数据 @@ -76,7 +76,7 @@ async def get_{{ moduleName }}_{{ businessName }}_list( async def add_{{ moduleName }}_{{ businessName }}( request: Request, add_{{ businessName }}: {{ BusinessName }}Model, - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: {% for column in columns %} @@ -108,7 +108,7 @@ async def add_{{ moduleName }}_{{ businessName }}( async def edit_{{ moduleName }}_{{ businessName }}( request: Request, edit_{{ businessName }}: {{ BusinessName }}Model, - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: {% for column in columns %} @@ -135,7 +135,7 @@ async def edit_{{ moduleName }}_{{ businessName }}( async def delete_{{ moduleName }}_{{ businessName }}( request: Request, {{ pk_field }}s: Annotated[str, Path(description='需要删除的{{ pk_field_comment }}')], - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], ) -> Response: delete_{{ businessName }} = Delete{{ BusinessName }}Model({{ pkField }}s={{ pk_field }}s) delete_{{ businessName }}_result = await {{ BusinessName }}Service.delete_{{ businessName }}_services(query_db, delete_{{ businessName }}) @@ -154,7 +154,7 @@ async def delete_{{ moduleName }}_{{ businessName }}( async def query_detail_{{ moduleName }}_{{ businessName }}( request: Request, {{ pk_field }}: Annotated[{{ pkColumn.python_type }}, Path(description='{{ pk_field_comment }}')], - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], ) -> Response: {{ businessName }}_detail_result = await {{ BusinessName }}Service.{{ businessName }}_detail_services(query_db, {{ pk_field }}) logger.info(f'获取{{ pk_field }}为{% raw %}{{% endraw %}{{ pk_field }}{% raw %}}{% endraw %}的信息成功') @@ -181,7 +181,7 @@ async def query_detail_{{ moduleName }}_{{ businessName }}( async def export_{{ moduleName }}_{{ businessName }}_list( request: Request, {{ businessName }}_page_query: Annotated[{{ BusinessName }}PageQueryModel, Form()], - query_db: Annotated[AsyncSession, DBSessionDependency()], + query_db: Annotated[AsyncSession, {{ dbSessionDependency }}], ) -> Response: # 获取全量数据 {{ businessName }}_query_result = await {{ BusinessName }}Service.get_{{ businessName }}_list_services(query_db, {{ businessName }}_page_query, is_page=False) diff --git a/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 index 76137e3..13e6a7a 100644 --- a/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 +++ b/ruoyi-fastapi-backend/module_generator/templates/python/do.py.jinja2 @@ -5,10 +5,17 @@ from sqlalchemy.orm import relationship {% endif %} +{% if dataSourceName == defaultDataSourceName %} from config.database import Base +{% else %} +from config.database import get_data_source_base -class {{ ClassName }}(Base): +DataSourceBase = get_data_source_base('{{ dataSourceName }}') +{% endif %} + + +class {{ ClassName }}({% if dataSourceName == defaultDataSourceName %}Base{% else %}DataSourceBase{% endif %}): """ {{ functionName }}表 """ @@ -17,7 +24,7 @@ class {{ ClassName }}(Base): __table_args__ = {'comment': '{{ tableComment }}'} {% for column in columns %} - {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type(dataSourceName) }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') {% endfor %} {% if table.sub %} @@ -26,7 +33,7 @@ class {{ ClassName }}(Base): {% if table.sub %} -class {{ subClassName }}(Base): +class {{ subClassName }}({% if dataSourceName == defaultDataSourceName %}Base{% else %}DataSourceBase{% endif %}): """ {{ subTable.function_name }}表 """ @@ -34,7 +41,7 @@ class {{ subClassName }}(Base): __tablename__ = '{{ subTableName }}' {% for column in subTable.columns %} - {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type }}, {% if column.column_name == subTableFkName %}ForeignKey('{{ tableName }}.{{ subTableFkName }}'), {% endif %}{% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required %}nullable=True{% else %}nullable=False{% endif %}, comment='{{ column.column_comment }}') + {{ column.column_name }} = Column({{ column.column_type | get_sqlalchemy_type(dataSourceName) }}, {% if column.column_name == subTableFkName %}ForeignKey('{{ tableName }}.{{ subTableFkName }}'), {% endif %}{% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') {% endfor %} {% if table.sub %} diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py index 62250c9..98c9b04 100644 --- a/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.annotation.log_annotation import Log -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.enums import BusinessType diff --git a/ruoyi-fastapi-backend/module_task/file_task.py b/ruoyi-fastapi-backend/module_task/file_task.py index 99a217a..a97bac1 100644 --- a/ruoyi-fastapi-backend/module_task/file_task.py +++ b/ruoyi-fastapi-backend/module_task/file_task.py @@ -1,4 +1,4 @@ -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from module_admin.service.file_business_service import FileRetentionNoticeService from module_admin.service.file_service import FileLifecycleService, FileReconcileService from utils.log_util import logger @@ -16,7 +16,7 @@ async def scan_retention_reminders(remind_days: int = 7, batch_size: int = 500) """ expiring_count = 0 expired_count = 0 - async with AsyncSessionLocal() as query_db: + async with DataSourceRegistry.session() as query_db: for _ in range(MAX_TASK_BATCHES): scan_result = await FileRetentionNoticeService.scan_file_retention_notices_services( query_db, @@ -41,7 +41,7 @@ async def purge_recycle_bin(retention_days: int = 30, batch_size: int = 100) -> :return: None """ purge_count = 0 - async with AsyncSessionLocal() as query_db: + async with DataSourceRegistry.session() as query_db: for _ in range(MAX_TASK_BATCHES): current_count = await FileLifecycleService.purge_recycle_bin_services( query_db, diff --git a/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py b/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py index 1b2cc3e..448bfb7 100644 --- a/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py +++ b/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py @@ -9,7 +9,7 @@ from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py b/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py index 131550c..d7d4935 100644 --- a/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py +++ b/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py @@ -9,7 +9,7 @@ from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log, RequestLogFieldRoot from common.aspect.data_scope import DataScopeDependency -from common.aspect.db_seesion import DBSessionDependency +from common.aspect.db_session import DBSessionDependency from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace diff --git a/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py b/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py index c13c279..6928d99 100644 --- a/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py +++ b/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py @@ -20,7 +20,7 @@ class AiModels(Base): model_name = Column( String(100), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='模型名称', ) provider = Column(String(50), nullable=False, comment='提供商') @@ -28,19 +28,19 @@ class AiModels(Base): api_key = Column( String(255), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='API Key', ) base_url = Column( String(255), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='Base URL', ) model_type = Column( String(50), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='模型类型', ) max_tokens = Column(Integer, nullable=True, comment='最大输出token') @@ -57,6 +57,6 @@ class AiModels(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml index 4e1da1c..439211f 100644 --- a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml +++ b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml @@ -88,13 +88,13 @@ dependencies: - portkey-ai==2.3.4 npm: - '@antv/infographic^0.2.13' - - '@terrastruct/d2>=0.1.33' + - '@terrastruct/d2==0.1.33' - katex>=0.16.27 - - markstream-vue2^0.0.50 + - markstream-vue2==0.0.50 - mermaid>=11.15.0 - shiki^3.21.0 - - stream-markdown>=0.0.16 - - stream-monaco>=0.0.48 + - stream-markdown==0.0.16 + - stream-monaco==0.0.48 npmDev: [] plugins: [] diff --git a/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py b/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py index 39dd781..6a07066 100644 --- a/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py +++ b/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from urllib.parse import urlparse -from config.database import async_engine +from config.database import DataSourceRegistry from config.env import DataBaseConfig if TYPE_CHECKING: @@ -111,11 +111,13 @@ def get_storage_engine(cls) -> 'AsyncBaseDb': :return: 存储引擎实例 """ - storage_engine_class = cls._resolve_storage_class(DataBaseConfig.db_type) + default_source = DataBaseConfig.db_default_source + source_config = DataBaseConfig.default_source + storage_engine_class = cls._resolve_storage_class(source_config.db_type) return storage_engine_class( - db_engine=async_engine, - db_schema=DataBaseConfig.db_database if DataBaseConfig.db_type == 'mysql' else 'public', + db_engine=DataSourceRegistry.get_async_engine(default_source), + db_schema=source_config.db_database if source_config.db_type == 'mysql' else 'public', session_table='ai_sessions', memory_table='ai_memories', metrics_table='ai_metrics', diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py index 5ee70ee..32242ec 100644 --- a/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py @@ -564,7 +564,7 @@ def _filter_current_database_migrations(cls, migration_paths: list[str]) -> list return PluginLifecycleScriptHelper.filter_current_database_paths( migration_paths, root_dir='migrations', - database_type=DataBaseConfig.db_type, + database_type=DataBaseConfig.default_source.db_type, ) def _load_migration_module(self, migration_file: Path) -> Any: diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py index 5c93a28..9f09a26 100644 --- a/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py @@ -138,7 +138,7 @@ def _filter_current_database_seeds(cls, seed_paths: list[str]) -> list[str]: return PluginLifecycleScriptHelper.filter_current_database_paths( seed_paths, root_dir='seeds', - database_type=DataBaseConfig.db_type, + database_type=DataBaseConfig.default_source.db_type, ) def _load_seed_module(self, seed_file: Path) -> Any: diff --git a/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py b/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py index 88b30c1..b0f2046 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py +++ b/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py @@ -48,7 +48,7 @@ class SysPlugin(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) @@ -154,6 +154,6 @@ class SysPluginOperationLog(Base): remark = Column( String(500), nullable=True, - server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.default_source.db_type), comment='备注', ) diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py index a3d3028..c19f020 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py +++ b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py @@ -202,7 +202,7 @@ def get_async_session_local() -> AsyncSessionFactoryProtocol: :return: 异步数据库会话工厂 """ - return import_module('config.database').AsyncSessionLocal + return import_module('config.database').DataSourceRegistry.session @staticmethod def get_plugin_service() -> type[PluginManagementServiceProtocol]: diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py b/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py index ff9c707..7d9a999 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py @@ -3,7 +3,7 @@ from fastapi import Depends, params from sqlalchemy.ext.asyncio import AsyncSession -from config.get_db import get_db +from common.aspect.db_session import DBSessionDependency from exceptions.exception import PermissionException @@ -56,7 +56,7 @@ def __init__(self, plugin_id: str, state_gateway: PluginRouteStateGateway | None self.plugin_id = plugin_id self.state_gateway = state_gateway or UnavailablePluginRouteStateGateway() - async def __call__(self, db: AsyncSession = Depends(get_db)) -> bool: + async def __call__(self, db: AsyncSession = DBSessionDependency()) -> bool: """ 执行插件启用状态校验。 diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py index d84178a..48966ff 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py @@ -8,9 +8,8 @@ from fastapi import FastAPI from common.router import auto_register_controller_files -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from config.env import AppConfig, get_config -from config.get_db import get_db from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin from plugins.core.lifecycle.migration import ( PluginMigrationHistoryRecord, @@ -315,7 +314,7 @@ async def requires_startup_write(self) -> bool: if not discovered_plugin_ids: return False - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: plugin_list = await self.management_gateway.list_plugins(query_db) database_plugin_map = {plugin.plugin_id: plugin for plugin in plugin_list} return any( @@ -423,7 +422,7 @@ async def recover_plugin_dependency_errors( :return: None """ recovered = False - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: for plugin in plugins: result = await self.management_gateway.recover_plugin_dependency_error( query_db, @@ -589,7 +588,7 @@ async def load_registry_from_database(self, app: FastAPI) -> None: :param app: FastAPI对象 :return: None """ - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: plugin_list = await self.management_gateway.list_plugins(query_db) app.state.plugin_registry = self.builder.build_registry(plugin_list) @@ -662,7 +661,7 @@ async def install_plugin_resources_with_isolation( ): logger.info('🔄 开始同步单插件启动资源') try: - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: try: await self.management_gateway.install_plugin_resources( query_db, @@ -743,7 +742,7 @@ async def sync_plugin_install(self, discovered_plugin: Any, *, enabled: bool) -> ): logger.info('🔄 开始执行插件启动安装生命周期') self.validate_plugin_structure(discovered_plugin) - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: try: await self.management_gateway.upsert_discovered_plugin( query_db, @@ -820,10 +819,10 @@ async def run_plugin_install_scripts(self, query_db: Any, discovered_plugin: Any :param discovered_plugin: 已发现插件对象 :return: None """ - async with AsyncSessionLocal() as migration_session: + async with DataSourceRegistry.session() as migration_session: await PluginMigrationRunner( discovered_plugin, - PluginStartupMigrationHistoryStore(self.management_gateway, AsyncSessionLocal), + PluginStartupMigrationHistoryStore(self.management_gateway, DataSourceRegistry.session), manage_execution_transaction=True, ).run(migration_session) await self.run_plugin_seed_scripts(query_db, discovered_plugin) @@ -996,7 +995,7 @@ async def mark_plugin_runtime_error(self, app: FastAPI, plugin_id: str, error_me :param error_message: 错误信息 :return: None """ - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: result = await self.management_gateway.mark_plugin_error(query_db, plugin_id, error_message) if not result.is_success: await query_db.rollback() @@ -1037,7 +1036,7 @@ async def sync_default_enabled_builtin_plugin_install_states(self) -> set[str]: return set() failed_plugin_ids: set[str] = set() - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: plugin_list = await self.management_gateway.list_plugins(query_db) database_plugin_map = {plugin.plugin_id: plugin for plugin in plugin_list} plugins_to_sync = [ @@ -1083,7 +1082,7 @@ async def mark_discovered_plugin_startup_error( :return: None """ plugin_id = discovered_plugin.manifest.id - async for query_db in get_db(): + async with DataSourceRegistry.session() as query_db: try: await self.management_gateway.upsert_discovered_plugin( query_db, diff --git a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py index 910e701..d1e660d 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py @@ -607,7 +607,7 @@ def _check_compatibility(self, manifest: PluginManifest) -> list[PluginValidatio ) ) - current_database = DataBaseConfig.db_type + current_database = DataBaseConfig.default_source.db_type if compatibility.databases and current_database not in compatibility.databases: issues.append( PluginValidationIssue( diff --git a/ruoyi-fastapi-backend/scripts/migrate_legacy_files.py b/ruoyi-fastapi-backend/scripts/migrate_legacy_files.py index 20dfa05..5c6600e 100644 --- a/ruoyi-fastapi-backend/scripts/migrate_legacy_files.py +++ b/ruoyi-fastapi-backend/scripts/migrate_legacy_files.py @@ -11,7 +11,7 @@ import aiofiles from pydantic import ValidationError -from config.database import AsyncSessionLocal +from config.database import DataSourceRegistry from config.env import UploadConfig from module_admin.dao.file_info_dao import FileInfoDao from module_admin.entity.vo.file_vo import FileInfoModel @@ -222,7 +222,7 @@ async def migrate_legacy_files( legacy_files, skipped_count = await asyncio.to_thread(collect_legacy_files) added_count = 0 pending_count = 0 - async with AsyncSessionLocal() as session: + async with DataSourceRegistry.session() as session: for legacy_file in legacy_files: if await FileInfoDao.get_file_info_by_storage_key(session, legacy_file.storage_key): skipped_count += 1 diff --git a/ruoyi-fastapi-backend/server.py b/ruoyi-fastapi-backend/server.py index 8829757..2e93012 100644 --- a/ruoyi-fastapi-backend/server.py +++ b/ruoyi-fastapi-backend/server.py @@ -6,10 +6,11 @@ from common.constant import LockConstant from common.router import auto_register_routers +from config.database import DataSourceRegistry from config.env import AppConfig -from config.get_db import close_async_engine, init_create_table from config.get_redis import RedisUtil from config.get_scheduler import SchedulerUtil +from config.lifecycle import init_create_table from exceptions.handle import handle_exception from middlewares.handle import handle_middleware from module_admin.service.log_service import LogAggregatorService @@ -49,13 +50,55 @@ async def _stop_background_tasks(app: FastAPI) -> None: pass finally: try: - # Scheduler负责停止续期并释放Application租约,必须先于Redis连接池关闭。 - await SchedulerUtil.close_system_scheduler() + redis = getattr(app.state, 'redis', None) + if redis is not None: + try: + # Scheduler负责停止续期并释放Application租约,必须先于Redis连接池关闭。 + await SchedulerUtil.close_system_scheduler() + finally: + await RedisUtil.close_redis_pool(app) finally: - try: - await RedisUtil.close_redis_pool(app) - finally: - await close_async_engine() + await DataSourceRegistry.dispose_all() + + +async def _initialize_application_runtime(app: FastAPI, application_leader: bool) -> None: + """ + 初始化应用运行时资源。 + + :param app: FastAPI对象 + :param application_leader: 当前worker是否为Application leader + :return: None + """ + await DataSourceRegistry.initialize(log_enabled=application_leader) + + plugin_runtime = get_plugin_application_runtime() + plugin_runtime.prepare_metadata(app) + + await init_create_table( + stage='platform', + log_success_enabled=application_leader, + ) + + async def create_plugin_entity_tables() -> None: + """在插件 writer 导入实体后同步插件表。""" + await init_create_table( + stage='plugin_entities', + log_success_enabled=True, + ) + + await plugin_runtime.startup( + app, + create_tables=create_plugin_entity_tables, + ) + app.state.plugin_application_runtime_started = True + await RedisUtil.check_redis_connection( + app.state.redis, + log_enabled=application_leader, + log_error_enabled=True, + ) + await RedisUtil.init_sys_dict(app.state.redis) + await RedisUtil.init_sys_config(app.state.redis) + await _start_background_tasks(app) async def _shutdown_application_runtime(app: FastAPI) -> None: @@ -76,6 +119,43 @@ async def _shutdown_application_runtime(app: FastAPI) -> None: await logger.complete() +def _log_address_group( + title: str, + local_ip: str, + network_ips: list[str], + *, + path: str = '', +) -> None: + """输出一组本地和网络访问地址。""" + port = AppConfig.app_port + links = [f'🏠 Local: http://{local_ip}:{port}{path}'] + links.extend(f'📡 Network: http://{ip}:{port}{path}' for ip in network_ips) + logger.opt(colors=True).info(f'{title}:\n' + '\n'.join(links)) + + +def _show_startup_addresses() -> None: + """ + 显示应用及接口文档访问地址 + + :return: None + """ + host = AppConfig.app_host + if host == '0.0.0.0': + local_ip = IPUtil.get_local_ip() + network_ips = IPUtil.get_network_ips() + else: + local_ip = host + network_ips = [host] + + _log_address_group('💻 应用地址', local_ip, network_ips) + + if not AppConfig.app_disable_swagger: + _log_address_group('📄 Swagger文档', local_ip, network_ips, path=APIDocsUtil.docs_url()) + + if not AppConfig.app_disable_redoc: + _log_address_group('📚 ReDoc文档', local_ip, network_ips, path=APIDocsUtil.redoc_url()) + + # 生命周期事件 @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: @@ -85,9 +165,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: :param app: FastAPI对象 :return: None """ - app.state.redis = await RedisUtil.create_redis_pool(log_enabled=False) + app.state.redis = None app.state.plugin_application_runtime_started = False try: + app.state.redis = await RedisUtil.create_redis_pool(log_enabled=False) application_lock_owner_token = SchedulerUtil.get_application_lock_owner_token() application_leader = await StartupUtil.acquire_application_leader( redis=app.state.redis, @@ -119,32 +200,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # 短暂等待确保下面的启动日志在最后打印 await asyncio.sleep(1) startup_logger.info(f'🚀 {AppConfig.app_name}启动成功') - host = AppConfig.app_host - port = AppConfig.app_port - if host == '0.0.0.0': - local_ip = IPUtil.get_local_ip() - network_ips = IPUtil.get_network_ips() - else: - local_ip = host - network_ips = [host] - - app_links = [f'🏠 Local: http://{local_ip}:{port}'] - app_links.extend(f'📡 Network: http://{ip}:{port}' for ip in network_ips) - logger.opt(colors=True).info('💻 应用地址:\n' + '\n'.join(app_links)) - - if not AppConfig.app_disable_swagger: - swagger_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.docs_url()}'] - swagger_links.extend( - f'📡 Network: http://{ip}:{port}{APIDocsUtil.docs_url()}' for ip in network_ips - ) - logger.opt(colors=True).info('📄 Swagger文档:\n' + '\n'.join(swagger_links)) - - if not AppConfig.app_disable_redoc: - redoc_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.redoc_url()}'] - redoc_links.extend( - f'📡 Network: http://{ip}:{port}{APIDocsUtil.redoc_url()}' for ip in network_ips - ) - logger.opt(colors=True).info('📚 ReDoc文档:\n' + '\n'.join(redoc_links)) + _show_startup_addresses() # 确保启动阶段的插件摘要在ASGI lifespan启动完成前已写入stdout和日志文件。 await logger.complete() yield @@ -152,44 +208,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await _shutdown_application_runtime(app) -async def _initialize_application_runtime(app: FastAPI, application_leader: bool) -> None: - """ - 初始化应用运行时资源。 - - :param app: FastAPI对象 - :param application_leader: 当前worker是否为Application leader - :return: None - """ - plugin_runtime = get_plugin_application_runtime() - plugin_runtime.prepare_metadata(app) - - await init_create_table( - stage='platform', - log_success_enabled=application_leader, - ) - - async def create_plugin_entity_tables() -> None: - """在插件 writer 导入实体后同步插件表。""" - await init_create_table( - stage='plugin_entities', - log_success_enabled=True, - ) - - await plugin_runtime.startup( - app, - create_tables=create_plugin_entity_tables, - ) - app.state.plugin_application_runtime_started = True - await RedisUtil.check_redis_connection( - app.state.redis, - log_enabled=application_leader, - log_error_enabled=True, - ) - await RedisUtil.init_sys_dict(app.state.redis) - await RedisUtil.init_sys_config(app.state.redis) - await _start_background_tasks(app) - - def create_app() -> FastAPI: """ 创建FastAPI应用 diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql index 1e9e065..75db4e9 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql @@ -939,6 +939,7 @@ create table gen_table ( table_id bigserial not null, table_name varchar(200) default '', table_comment varchar(500) default '', + data_source_name varchar(64) not null default 'primary', sub_table_name varchar(64) default null, sub_table_fk_name varchar(64) default null, class_name varchar(100) default '', @@ -963,6 +964,7 @@ create table gen_table ( comment on column gen_table.table_id is '编号'; comment on column gen_table.table_name is '表名称'; comment on column gen_table.table_comment is '表描述'; +comment on column gen_table.data_source_name is '目标数据源名称'; comment on column gen_table.sub_table_name is '关联子表的表名'; comment on column gen_table.sub_table_fk_name is '子表关联的外键名'; comment on column gen_table.class_name is '实体类名称'; diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql index 8b502a8..f4bdaf8 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql @@ -730,6 +730,7 @@ create table gen_table ( table_id bigint(20) not null auto_increment comment '编号', table_name varchar(200) default '' comment '表名称', table_comment varchar(500) default '' comment '表描述', + data_source_name varchar(64) not null default 'primary' comment '目标数据源名称', sub_table_name varchar(64) default null comment '关联子表的表名', sub_table_fk_name varchar(64) default null comment '子表关联的外键名', class_name varchar(100) default '' comment '实体类名称', diff --git a/ruoyi-fastapi-backend/tests/cli/core/test_context_factory.py b/ruoyi-fastapi-backend/tests/cli/core/test_context_factory.py new file mode 100644 index 0000000..3501815 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/core/test_context_factory.py @@ -0,0 +1,44 @@ +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock + +from pytest import MonkeyPatch + +from cli.core import context_factory +from cli.core.context_factory import CliRuntimeState + + +def test_suppress_sqlalchemy_logs_disables_echo_for_all_sources(monkeypatch: MonkeyPatch) -> None: + sources = { + 'primary': SimpleNamespace(db_echo=True), + 'reporting': SimpleNamespace(db_echo=True), + } + env_module = SimpleNamespace(DataBaseConfig=SimpleNamespace(db_sources=sources)) + loggers: dict[str, MagicMock] = {} + + monkeypatch.setattr(context_factory, 'import_module', lambda _name: env_module) + + def get_logger(name: str) -> MagicMock: + logger = MagicMock() + loggers[name] = logger + return logger + + monkeypatch.setattr( + context_factory, + 'logging', + SimpleNamespace(WARNING=logging.WARNING, getLogger=get_logger), + ) + + state = CliRuntimeState() + state.suppress_sqlalchemy_logs() + + assert all(not source.db_echo for source in sources.values()) + assert state.sqlalchemy_logs_suppressed is True + assert set(loggers) == { + 'sqlalchemy', + 'sqlalchemy.engine', + 'sqlalchemy.engine.Engine', + 'sqlalchemy.pool', + } + for logger in loggers.values(): + logger.setLevel.assert_called_once_with(logging.WARNING) diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/test_app_runtime.py b/ruoyi-fastapi-backend/tests/cli/runtime/test_app_runtime.py index d470776..02d55bd 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/test_app_runtime.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/test_app_runtime.py @@ -92,10 +92,12 @@ def test_app_snapshot_support_builds_config_snapshot() -> None: app_disable_redoc=False, ), DataBaseConfig=SimpleNamespace( - db_type='mysql', - db_host='127.0.0.1', - db_port=3306, - db_database='ruoyi', + default_source=SimpleNamespace( + db_type='mysql', + db_host='127.0.0.1', + db_port=3306, + db_database='ruoyi', + ) ), RedisConfig=SimpleNamespace(redis_host='127.0.0.1', redis_port=REDIS_PORT), LogConfig=SimpleNamespace(loguru_level='INFO'), diff --git a/ruoyi-fastapi-backend/tests/cli/tui/adapters/conftest.py b/ruoyi-fastapi-backend/tests/cli/tui/adapters/conftest.py index e86d268..813cbbf 100644 --- a/ruoyi-fastapi-backend/tests/cli/tui/adapters/conftest.py +++ b/ruoyi-fastapi-backend/tests/cli/tui/adapters/conftest.py @@ -209,14 +209,13 @@ def dispose_async_db_engine_after_test() -> None: 在每个 TUI adapter 测试结束后尝试释放全局异步数据库连接池。 这些适配器测试会按需导入运行时模块;若其中某些路径触发真实数据库访问, - 模块级 `async_engine` 可能在测试进程结束前仍持有连接,从而在 GC 阶段产生 - SQLAlchemy 未归还连接告警。这里统一在测试后主动 `dispose()`,将清理职责收口 - 到测试夹具而非业务代码。 + 注册表中的引擎可能在测试进程结束前仍持有连接,从而在 GC 阶段产生 + SQLAlchemy 未归还连接告警。这里统一在测试后主动释放注册表资源,将清理职责 + 收口到测试夹具而非业务代码。 :return: None """ yield database_module = sys.modules.get('config.database') - async_engine = getattr(database_module, 'async_engine', None) if database_module is not None else None - if async_engine is not None: - asyncio.run(async_engine.dispose()) + if database_module is not None: + asyncio.run(database_module.DataSourceRegistry.dispose_all()) diff --git a/ruoyi-fastapi-backend/tests/config/test_database_exception_handlers.py b/ruoyi-fastapi-backend/tests/config/test_database_exception_handlers.py new file mode 100644 index 0000000..5b0f396 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_database_exception_handlers.py @@ -0,0 +1,37 @@ +import json + +import pytest +from fastapi import FastAPI, status + +from common.constant import HttpStatusConstant +from exceptions.exception import ( + DataSourceInitializationException, + DataSourceNotFoundException, + DataSourceUnavailableException, + ServiceException, +) +from exceptions.handle import handle_exception + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'exception', + [ + DataSourceUnavailableException('reporting'), + DataSourceInitializationException('reporting'), + DataSourceNotFoundException('reporting'), + ], +) +async def test_data_source_exceptions_use_service_exception_handler(exception: Exception) -> None: + app = FastAPI() + handle_exception(app) + + handler = app.exception_handlers[ServiceException] + response = await handler(None, exception) + payload = json.loads(response.body) + + assert response.status_code == status.HTTP_200_OK + assert payload['code'] == HttpStatusConstant.ERROR + assert payload['success'] is False + assert 'reporting' in payload['msg'] + assert '://' not in payload['msg'] diff --git a/ruoyi-fastapi-backend/tests/config/test_database_registry.py b/ruoyi-fastapi-backend/tests/config/test_database_registry.py new file mode 100644 index 0000000..8fda15d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_database_registry.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, call + +import pytest +from sqlalchemy import URL +from sqlalchemy.exc import OperationalError + +from common.aspect.db_session import DBSessionDependency, get_db_session_provider +from config import database +from exceptions.exception import DataSourceInitializationException, DataSourceUnavailableException + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + +def _source(*, required: bool = True) -> SimpleNamespace: + return SimpleNamespace( + db_type='mysql', + db_host='db.example', + db_port=3306, + db_username='user', + db_password='p@ss/w0rd', + db_database='application', + db_echo=False, + db_connect_timeout=7, + db_max_overflow=1, + db_pool_size=1, + db_pool_recycle=60, + db_pool_timeout=2, + db_required=required, + ) + + +def test_database_urls_use_structured_url_and_hide_password() -> None: + config = _source() + async_url = database.build_async_sqlalchemy_database_url(config) + sync_url = database.build_sync_sqlalchemy_database_url(config) + + assert isinstance(async_url, URL) + assert async_url.drivername == 'mysql+asyncmy' + assert sync_url.drivername == 'mysql+pymysql' + assert async_url.password == 'p@ss/w0rd' + assert 'p@ss' not in repr(async_url) + + +@pytest.mark.parametrize( + ('db_type', 'db_port', 'async_timeout_key', 'sync_timeout_key'), + [ + ('mysql', 3306, 'connect_timeout', 'connect_timeout'), + ('postgresql', 5432, 'timeout', 'connect_timeout'), + ], +) +def test_engine_factories_use_driver_specific_connect_timeout( + monkeypatch: pytest.MonkeyPatch, + db_type: str, + db_port: int, + async_timeout_key: str, + sync_timeout_key: str, +) -> None: + config = _source() + config.db_type = db_type + config.db_port = db_port + async_engine = object() + sync_engine = object() + captured_options: dict[str, dict[str, object]] = {} + + def create_async_engine(_url: URL, **options: object) -> object: + captured_options['async'] = options + return async_engine + + def create_sync_engine(_url: URL, **options: object) -> object: + captured_options['sync'] = options + return sync_engine + + monkeypatch.setattr(database, 'create_async_engine', create_async_engine) + monkeypatch.setattr(database, 'create_engine', create_sync_engine) + + assert database.create_async_db_engine(config=config) is async_engine + assert database.create_sync_db_engine(config=config) is sync_engine + assert captured_options['async']['connect_args'] == {async_timeout_key: 7} + assert captured_options['sync']['connect_args'] == {sync_timeout_key: 7} + assert captured_options['async']['pool_use_lifo'] is True + assert captured_options['sync']['pool_use_lifo'] is True + + +def test_async_session_factory_disables_expiration_after_commit() -> None: + factory = database.create_async_session_factory(MagicMock()) + + assert factory.kw['expire_on_commit'] is False + + +class _Begin: + def __init__(self, should_fail: bool = False) -> None: + self.should_fail = should_fail + + async def __aenter__(self) -> _Begin: + if self.should_fail: + raise RuntimeError('password=secret') + return self + + async def __aexit__(self, *_args: object) -> bool: + return False + + async def execute(self, _statement: object) -> None: + return None + + +class _Engine: + def __init__(self) -> None: + self.should_fail = True + self.dispose = AsyncMock() + + def begin(self) -> _Begin: + return _Begin(self.should_fail) + + +@pytest.mark.asyncio +async def test_initialize_can_suppress_worker_startup_logs(monkeypatch: pytest.MonkeyPatch) -> None: + primary_engine = _Engine() + primary_engine.should_fail = False + optional_engine = _Engine() + monkeypatch.setattr( + database, + 'create_async_db_engine', + lambda config: primary_engine if config.db_required else optional_engine, + ) + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': _source(), 'reporting': _source(required=False)}, + ) + ) + source_logger = MagicMock() + monkeypatch.setattr(database, 'logger', source_logger) + + await registry.initialize(log_enabled=False) + + source_logger.bind.assert_not_called() + assert registry._runtimes['primary'].available + assert not registry._runtimes['reporting'].available + + optional_engine.should_fail = False + registry._runtimes['reporting'].next_retry_at = None + async with registry.connection('reporting'): + pass + source_logger.bind.assert_not_called() + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_initialize_logs_source_name(monkeypatch: pytest.MonkeyPatch) -> None: + engine = _Engine() + engine.should_fail = False + monkeypatch.setattr(database, 'create_async_db_engine', lambda config: engine) + registry = database._DataSourceRegistry( + SimpleNamespace(db_default_source='primary', db_sources={'primary': _source()}) + ) + source_logger = MagicMock() + monkeypatch.setattr(database, 'logger', source_logger) + + await registry.initialize() + + source_logger.bind.assert_called_once_with(data_source='primary', database_type='mysql', required=True) + source_logger.bind.return_value.info.assert_called_once_with('✅ 数据源 primary 初始化成功') + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_optional_source_recovers_after_cooldown(monkeypatch: pytest.MonkeyPatch) -> None: + primary_engine = _Engine() + primary_engine.should_fail = False + optional_engine = _Engine() + monkeypatch.setattr( + database, + 'create_async_db_engine', + lambda config: primary_engine if config.db_required else optional_engine, + ) + settings = SimpleNamespace( + db_default_source='primary', + db_sources={'primary': _source(), 'reporting': _source(required=False)}, + ) + registry = database._DataSourceRegistry(settings) + + await registry.initialize() + assert not registry._runtimes['reporting'].available + optional_engine.should_fail = False + runtime = registry._runtimes['reporting'] + runtime.next_retry_at = None + async with registry.connection('reporting'): + pass + assert runtime.available + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_optional_engine_creation_failure_degrades_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None: + primary_config = _source() + optional_config = _source(required=False) + primary_engine = _Engine() + primary_engine.should_fail = False + optional_engine = _Engine() + optional_engine.should_fail = False + optional_creation_fails = True + + def create_engine(config: SimpleNamespace) -> _Engine: + nonlocal optional_creation_fails + if config is optional_config and optional_creation_fails: + raise ModuleNotFoundError('driver is not installed') + return optional_engine if config is optional_config else primary_engine + + monkeypatch.setattr(database, 'create_async_db_engine', create_engine) + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': primary_config, 'reporting': optional_config}, + ) + ) + + await registry.initialize() + runtime = registry._runtimes['reporting'] + assert runtime.async_engine is None + assert not runtime.available + + optional_creation_fails = False + runtime.next_retry_at = None + async with registry.connection('reporting'): + pass + + assert runtime.async_engine is optional_engine + assert runtime.available + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_required_engine_creation_failure_disposes_other_sources(monkeypatch: pytest.MonkeyPatch) -> None: + primary_config = _source() + optional_config = _source(required=False) + optional_engine = _Engine() + optional_engine.should_fail = False + + def create_engine(config: SimpleNamespace) -> _Engine: + if config is primary_config: + raise ModuleNotFoundError('required driver is not installed') + return optional_engine + + monkeypatch.setattr(database, 'create_async_db_engine', create_engine) + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': primary_config, 'reporting': optional_config}, + ) + ) + + with pytest.raises(DataSourceInitializationException): + await registry.initialize() + + optional_engine.dispose.assert_awaited_once_with() + assert registry._runtimes == {} + + +@pytest.mark.asyncio +async def test_initialize_logs_all_sources_and_safe_failure_details(monkeypatch: pytest.MonkeyPatch) -> None: + primary_engine = _Engine() + reporting_engine = _Engine() + reporting_engine.should_fail = False + monkeypatch.setattr( + database, + 'create_async_db_engine', + lambda config: primary_engine if config.db_required else reporting_engine, + ) + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': _source(), 'reporting': _source(required=False)}, + ) + ) + source_logger = MagicMock() + monkeypatch.setattr(database, 'logger', source_logger) + + with pytest.raises(DataSourceInitializationException) as exc_info: + await registry.initialize() + + assert exc_info.value.error_type == 'RuntimeError' + assert exc_info.value.error_code is None + assert source_logger.bind.call_args_list == [ + call( + data_source='primary', + database_type='mysql', + required=True, + error_type='RuntimeError', + error_code=None, + ), + call(data_source='reporting', database_type='mysql', required=False), + ] + source_logger.bind.return_value.error.assert_called_once_with( + '❌ 必需数据源 primary 连接检查失败,错误类型:RuntimeError' + ) + source_logger.bind.return_value.info.assert_called_once_with('✅ 数据源 reporting 初始化成功') + assert 'secret' not in str(source_logger.mock_calls) + + +@pytest.mark.asyncio +async def test_invalidated_operational_error_marks_source_down_then_recovers(monkeypatch: pytest.MonkeyPatch) -> None: + engine = _Engine() + engine.should_fail = False + monkeypatch.setattr(database, 'create_async_db_engine', lambda config: engine) + registry = database._DataSourceRegistry( + SimpleNamespace(db_default_source='primary', db_sources={'primary': _source()}) + ) + await registry.initialize() + runtime = registry._runtimes['primary'] + session_closed = False + + @asynccontextmanager + async def session_factory() -> AsyncGenerator[object, None]: + nonlocal session_closed + try: + yield object() + finally: + session_closed = True + + runtime.async_session_factory = session_factory + with pytest.raises(DataSourceUnavailableException): + async with registry.session(): + raise OperationalError( + 'SELECT 1', + {}, + RuntimeError('connection lost'), + connection_invalidated=True, + ) + + assert session_closed + assert not runtime.available + with pytest.raises(DataSourceUnavailableException): + async with registry.session(): + pass + + runtime.next_retry_at = None + async with registry.session() as session: + assert session is not None + assert runtime.available + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_non_invalidated_operational_error_does_not_mark_source_down(monkeypatch: pytest.MonkeyPatch) -> None: + engine = _Engine() + engine.should_fail = False + monkeypatch.setattr(database, 'create_async_db_engine', lambda config: engine) + registry = database._DataSourceRegistry( + SimpleNamespace(db_default_source='primary', db_sources={'primary': _source()}) + ) + await registry.initialize() + runtime = registry._runtimes['primary'] + + @asynccontextmanager + async def session_factory() -> AsyncGenerator[object, None]: + yield object() + + runtime.async_session_factory = session_factory + + with pytest.raises(OperationalError): + async with registry.session(): + raise OperationalError('UPDATE sys_user', {}, RuntimeError('deadlock')) + + assert runtime.available + assert runtime.next_retry_at is None + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_multiple_sources_have_independent_engines_and_factories(monkeypatch: pytest.MonkeyPatch) -> None: + primary_config = _source() + reporting_config = _source(required=False) + engines = {id(primary_config): _Engine(), id(reporting_config): _Engine()} + for engine in engines.values(): + engine.should_fail = False + monkeypatch.setattr(database, 'create_async_db_engine', lambda config: engines[id(config)]) + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': primary_config, 'reporting': reporting_config}, + ) + ) + + await registry.initialize() + + assert registry.get_async_engine() is engines[id(primary_config)] + assert registry.get_async_engine('reporting') is engines[id(reporting_config)] + assert ( + registry._runtimes['primary'].async_session_factory is not registry._runtimes['reporting'].async_session_factory + ) + await registry.dispose_all() + + +@pytest.mark.asyncio +async def test_dispose_all_releases_sync_and_async_engines(monkeypatch: pytest.MonkeyPatch) -> None: + engine = _Engine() + engine.should_fail = False + sync_engine = MagicMock() + monkeypatch.setattr(database, 'create_async_db_engine', lambda config: engine) + monkeypatch.setattr(database, 'create_sync_db_engine', lambda config: sync_engine) + registry = database._DataSourceRegistry( + SimpleNamespace(db_default_source='primary', db_sources={'primary': _source()}) + ) + await registry.initialize() + assert registry.get_sync_engine() is sync_engine + + await registry.dispose_all() + + sync_engine.dispose.assert_called_once_with() + engine.dispose.assert_awaited_once_with() + assert registry._runtimes == {} + + +@pytest.mark.asyncio +async def test_dispose_all_attempts_every_engine_when_disposal_fails(monkeypatch: pytest.MonkeyPatch) -> None: + registry = database._DataSourceRegistry( + SimpleNamespace( + db_default_source='primary', + db_sources={'primary': _source(), 'reporting': _source(required=False)}, + ) + ) + primary = registry._runtime('primary') + reporting = registry._runtime('reporting') + primary.sync_engine = MagicMock() + reporting.sync_engine = MagicMock() + primary.sync_engine.dispose.side_effect = RuntimeError('sync failed') + primary.async_engine = MagicMock() + reporting.async_engine = MagicMock() + primary.async_engine.dispose = AsyncMock(side_effect=RuntimeError('async failed')) + reporting.async_engine.dispose = AsyncMock() + source_logger = MagicMock() + monkeypatch.setattr(database, 'logger', source_logger) + + await registry.dispose_all() + + primary.sync_engine.dispose.assert_called_once_with() + reporting.sync_engine.dispose.assert_called_once_with() + primary.async_engine.dispose.assert_awaited_once_with() + reporting.async_engine.dispose.assert_awaited_once_with() + expected_warning_count = 2 + assert source_logger.bind.return_value.warning.call_count == expected_warning_count + assert registry._runtimes == {} + + +def test_dependency_provider_is_cached_per_source() -> None: + get_db_session_provider.cache_clear() + assert get_db_session_provider('reporting') is get_db_session_provider('reporting') + assert get_db_session_provider('reporting') is not get_db_session_provider('archive') + assert DBSessionDependency('reporting').dependency is get_db_session_provider('reporting') diff --git a/ruoyi-fastapi-backend/tests/config/test_database_settings.py b/ruoyi-fastapi-backend/tests/config/test_database_settings.py new file mode 100644 index 0000000..5fc7f48 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_database_settings.py @@ -0,0 +1,124 @@ +import json + +import pytest +from pydantic import SecretStr, ValidationError + +from config.env import DataBaseSettings, DataSourceNotFoundException, DataSourceSettings + +DEFAULT_CONNECT_TIMEOUT = 10 + + +def _source(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + 'db_type': 'mysql', + 'db_host': 'db.example.test', + 'db_port': 3306, + 'db_username': 'app', + 'db_password': 'super-secret', + 'db_database': 'appdb', + } + values.update(overrides) + return values + + +def test_db_sources_json_is_parsed_and_password_is_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('DB_DEFAULT_SOURCE', 'primary') + monkeypatch.setenv('DB_SOURCES', json.dumps({'primary': _source()})) + + settings = DataBaseSettings(_env_file=None) + source = settings.default_source + + assert settings.get_source() is source + assert isinstance(source.db_password, SecretStr) + assert source.db_password.get_secret_value() == 'super-secret' + assert source.db_connect_timeout == DEFAULT_CONNECT_TIMEOUT + assert 'super-secret' not in repr(source) + assert source.sqlglot_parse_dialect == 'mysql' + + +def test_postgresql_uses_sqlglot_postgres_dialect() -> None: + source = DataSourceSettings(**_source(db_type='postgresql', db_port=5432)) + + assert source.sqlglot_parse_dialect == 'postgres' + + +def test_data_source_allows_disabling_pool_recycle() -> None: + source = DataSourceSettings(**_source(db_pool_recycle=-1)) + + assert source.db_pool_recycle == -1 + + +@pytest.mark.parametrize('db_connect_timeout', [0, -1]) +def test_data_source_rejects_invalid_connect_timeout(db_connect_timeout: int) -> None: + with pytest.raises(ValidationError): + DataSourceSettings(**_source(db_connect_timeout=db_connect_timeout)) + + +@pytest.mark.parametrize( + ('field', 'value', 'message'), + [ + ('DB_SOURCES', '{}', 'DB_SOURCES 不能为空'), + ('DB_DEFAULT_SOURCE', 'missing', '默认数据源不存在'), + ], +) +def test_database_settings_rejects_empty_or_unknown_default( + monkeypatch: pytest.MonkeyPatch, + field: str, + value: str, + message: str, +) -> None: + monkeypatch.setenv('DB_SOURCES', json.dumps({'primary': _source()})) + monkeypatch.setenv('DB_DEFAULT_SOURCE', 'primary') + monkeypatch.setenv(field, value) + + with pytest.raises(ValidationError, match=message): + DataBaseSettings(_env_file=None) + + +def test_database_settings_rejects_invalid_source_name() -> None: + with pytest.raises(ValidationError, match='数据源名称不合法'): + DataBaseSettings( + _env_file=None, + db_default_source='Primary', + db_sources={'Primary': DataSourceSettings(**_source())}, + ) + + +def test_database_settings_does_not_expose_legacy_flat_fields() -> None: + settings = DataBaseSettings( + _env_file=None, + db_sources={'primary': DataSourceSettings(**_source())}, + ) + + assert not hasattr(settings, 'db_type') + assert not hasattr(settings, 'db_password') + + +def test_get_source_rejects_unconfigured_name() -> None: + settings = DataBaseSettings( + _env_file=None, + db_sources={'primary': DataSourceSettings(**_source())}, + ) + + with pytest.raises(DataSourceNotFoundException): + settings.get_source('reporting') + + +def test_malformed_sources_json_does_not_echo_password(monkeypatch: pytest.MonkeyPatch) -> None: + secret = 'must-not-leak' + monkeypatch.setenv('DB_SOURCES', f'{{"primary":{{"db_password":"{secret}"') + + with pytest.raises(ValueError, match='DB_SOURCES JSON 格式错误') as exc_info: + DataBaseSettings(_env_file=None) + + assert secret not in str(exc_info.value) + + +def test_invalid_source_fields_do_not_echo_password(monkeypatch: pytest.MonkeyPatch) -> None: + secret = 'valid-json-secret' + monkeypatch.setenv('DB_SOURCES', json.dumps({'primary': {'db_type': 'mysql', 'db_password': secret}})) + + with pytest.raises(ValidationError) as exc_info: + DataBaseSettings(_env_file=None) + + assert secret not in str(exc_info.value) diff --git a/ruoyi-fastapi-backend/tests/config/test_lifecycle.py b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py new file mode 100644 index 0000000..8427ace --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py @@ -0,0 +1,25 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from config.database import Base +from config.lifecycle import init_create_table + + +@pytest.mark.asyncio +async def test_init_create_table_uses_registry_connection(monkeypatch: pytest.MonkeyPatch) -> None: + connection = SimpleNamespace(run_sync=AsyncMock()) + + @asynccontextmanager + async def connection_context() -> AsyncGenerator[object, None]: + yield connection + + registry = SimpleNamespace(connection=connection_context) + monkeypatch.setattr('config.lifecycle.DataSourceRegistry', registry) + + await init_create_table(log_success_enabled=False) + + connection.run_sync.assert_awaited_once_with(Base.metadata.create_all) diff --git a/ruoyi-fastapi-backend/tests/config/test_scheduler_database.py b/ruoyi-fastapi-backend/tests/config/test_scheduler_database.py new file mode 100644 index 0000000..971307e --- /dev/null +++ b/ruoyi-fastapi-backend/tests/config/test_scheduler_database.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock + +import pytest + +from config.env import DataBaseConfig +from config.get_scheduler import SchedulerUtil + + +def test_scheduler_database_engines_keep_jobstore_logging_isolated(monkeypatch: pytest.MonkeyPatch) -> None: + jobstore_engine = MagicMock() + listener_engine = MagicMock() + create_engine = MagicMock(return_value=jobstore_engine) + + monkeypatch.setattr(SchedulerUtil, '_jobstore_engine', None) + monkeypatch.setattr(SchedulerUtil, '_listener_engine', None) + monkeypatch.setattr(SchedulerUtil, '_session_local', None) + monkeypatch.setattr(SchedulerUtil, '_disposed_sync_engines', False) + monkeypatch.setattr('config.get_scheduler.create_sync_db_engine', create_engine) + monkeypatch.setattr('config.get_scheduler.DataSourceRegistry.get_sync_engine', lambda _name: listener_engine) + + assert SchedulerUtil._get_jobstore_engine() is jobstore_engine + assert SchedulerUtil._get_listener_engine() is listener_engine + assert jobstore_engine is not listener_engine + create_engine.assert_called_once_with(echo=False, config=DataBaseConfig.get_source()) + + +def test_scheduler_cleanup_does_not_dispose_registry_listener_engine(monkeypatch: pytest.MonkeyPatch) -> None: + jobstore_engine = MagicMock() + listener_engine = MagicMock() + monkeypatch.setattr(SchedulerUtil, '_jobstore_engine', jobstore_engine) + monkeypatch.setattr(SchedulerUtil, '_listener_engine', listener_engine) + monkeypatch.setattr(SchedulerUtil, '_session_local', MagicMock()) + monkeypatch.setattr(SchedulerUtil, '_disposed_sync_engines', False) + + SchedulerUtil._dispose_sync_engines() + + jobstore_engine.dispose.assert_called_once_with() + listener_engine.dispose.assert_not_called() + assert SchedulerUtil._jobstore_engine is None + assert SchedulerUtil._listener_engine is None diff --git a/ruoyi-fastapi-backend/tests/config/test_scheduler_leader_lease.py b/ruoyi-fastapi-backend/tests/config/test_scheduler_leader_lease.py index e7d4314..d8291fc 100644 --- a/ruoyi-fastapi-backend/tests/config/test_scheduler_leader_lease.py +++ b/ruoyi-fastapi-backend/tests/config/test_scheduler_leader_lease.py @@ -178,11 +178,6 @@ async def test_scheduler_close_releases_owner_lease_before_forgetting_redis() -> 'stop_application_lock_renewal', new_callable=AsyncMock, ) as stop_renewal, - patch.object( - SchedulerUtil, - '_dispose_sync_async_engine', - new_callable=AsyncMock, - ), patch.object(SchedulerUtil, '_dispose_sync_engines'), patch('config.get_scheduler.scheduler', running=False), patch( diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_access_log.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_access_log.py index e7ea796..e30f064 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_access_log.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_access_log.py @@ -31,6 +31,11 @@ async def __aexit__(self, exc_type: type | None, exc_value: BaseException | None return None +def make_database_registry(session: SimpleNamespace) -> SimpleNamespace: + """构造使用测试会话的数据库注册表。""" + return SimpleNamespace(session=lambda: AsyncSessionContext(session)) + + def make_file_access_log() -> FileAccessLogModel: return FileAccessLogModel( fileId='file-id', @@ -94,8 +99,8 @@ def test_file_access_log_event_is_persisted_and_acknowledged() -> None: with ( patch( - 'module_admin.service.log_service.AsyncSessionLocal', - return_value=AsyncSessionContext(session), + 'module_admin.service.log_service.DataSourceRegistry', + make_database_registry(session), ), patch.object(FileAccessLogDao, 'add_file_access_log_dao', new_callable=AsyncMock) as add_file_access_log, ): diff --git a/ruoyi-fastapi-backend/tests/module_generator/dao/test_gen_dao.py b/ruoyi-fastapi-backend/tests/module_generator/dao/test_gen_dao.py index 9a00938..f21c29b 100644 --- a/ruoyi-fastapi-backend/tests/module_generator/dao/test_gen_dao.py +++ b/ruoyi-fastapi-backend/tests/module_generator/dao/test_gen_dao.py @@ -1,4 +1,5 @@ -from unittest.mock import AsyncMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,6 +8,32 @@ from utils.page_util import PageUtil +@pytest.mark.asyncio +async def test_get_gen_table_by_name_is_scoped_to_data_source() -> None: + result = MagicMock() + result.scalars.return_value.first.return_value = None + db = MagicMock() + db.execute = AsyncMock(return_value=result) + + await GenTableDao.get_gen_table_by_name(db, 'orders', 'reporting') + + statement = db.execute.await_args.args[0] + params = statement.compile().params + assert 'orders' in params.values() + assert 'reporting' in params.values() + + +@pytest.mark.asyncio +async def test_get_gen_table_list_filters_source_when_requested() -> None: + query_object = GenTablePageQueryModel(dataSourceName='reporting') + + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=[])) as paginate: + await GenTableDao.get_gen_table_list(object(), query_object, is_page=True) + + query = paginate.await_args.args[1] + assert 'reporting' in query.compile().params.values() + + @pytest.mark.asyncio async def test_get_gen_db_table_list_does_not_bind_unreferenced_model_defaults() -> None: query_object = GenTablePageQueryModel(formColNum=3) @@ -41,3 +68,20 @@ async def test_get_gen_db_table_list_only_binds_active_sql_filters() -> None: 'end_time': '2026-07-30', } assert ')and ' not in compiled_sql + + +@pytest.mark.asyncio +async def test_get_gen_db_table_list_uses_target_source_config() -> None: + query_object = GenTablePageQueryModel() + + with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=[])) as paginate: + await GenTableDao.get_gen_db_table_list( + object(), + query_object, + excluded_table_names={'sys_user'}, + source_config=SimpleNamespace(db_type='postgresql'), + ) + + query = paginate.await_args.args[1] + assert 'list_table' in str(query.compile()) + assert query.compile().params['excluded_table_names'] == ('sys_user',) diff --git a/ruoyi-fastapi-backend/tests/module_generator/test_gen_service.py b/ruoyi-fastapi-backend/tests/module_generator/test_gen_service.py new file mode 100644 index 0000000..b7914b4 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_generator/test_gen_service.py @@ -0,0 +1,78 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy import create_engine, text + +from exceptions.exception import ServiceException +from module_generator.service.gen_service import GenTableService + + +def test_get_data_source_list_services_returns_camel_case_response(monkeypatch: pytest.MonkeyPatch) -> None: + settings = SimpleNamespace( + db_default_source='primary', + db_sources={ + 'primary': SimpleNamespace(db_type='mysql'), + 'reporting': SimpleNamespace(db_type='postgresql'), + }, + ) + monkeypatch.setattr('module_generator.service.gen_service.DataBaseConfig', settings) + + result = GenTableService.get_data_source_list_services() + + assert [item.model_dump(by_alias=True) for item in result] == [ + {'name': 'primary', 'dbType': 'mysql', 'isDefault': True}, + {'name': 'reporting', 'dbType': 'postgresql', 'isDefault': False}, + ] + + +@pytest.mark.asyncio +async def test_get_gen_db_table_list_by_name_services_transforms_rows(monkeypatch: pytest.MonkeyPatch) -> None: + engine = create_engine('sqlite:///:memory:') + with engine.connect() as connection: + rows = connection.execute( + text("select 'sys_user' as table_name, '用户表' as table_comment, null as create_time, null as update_time") + ).fetchall() + + @asynccontextmanager + async def session(_source_name: str) -> AsyncGenerator[object, None]: + yield SimpleNamespace() + + settings = SimpleNamespace( + db_default_source='primary', + get_source=lambda _source_name: SimpleNamespace(db_type='mysql'), + ) + registry = SimpleNamespace(session=session) + monkeypatch.setattr('module_generator.service.gen_service.DataBaseConfig', settings) + monkeypatch.setattr('module_generator.service.gen_service.DataSourceRegistry', registry) + + with patch( + 'module_generator.service.gen_service.GenTableDao.get_gen_db_table_list_by_names', + new=AsyncMock(return_value=rows), + ): + result = await GenTableService.get_gen_db_table_list_by_name_services(object(), ['sys_user'], 'reporting') + + assert result[0].table_name == 'sys_user' + assert result[0].data_source_name == 'reporting' + + +@pytest.mark.asyncio +async def test_batch_gen_code_services_rejects_tables_from_other_sources() -> None: + with ( + patch( + 'module_generator.service.gen_service.GenTableDao.get_gen_table_names', + new=AsyncMock(return_value={'sys_user'}), + ), + patch('module_generator.service.gen_service.TemplateInitializer.init_jinja2') as init_jinja2, + pytest.raises(ServiceException) as exc_info, + ): + await GenTableService.batch_gen_code_services( + object(), + ['sys_user', 'report_order'], + 'reporting', + ) + + assert exc_info.value.message == '业务表不存在或不属于数据源 reporting:report_order' + init_jinja2.assert_not_called() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_migration.py b/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_migration.py index 47da0b4..12c2419 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_migration.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_migration.py @@ -215,7 +215,7 @@ async def test_plugin_migration_runner_filters_database_dialect_migrations( discovered_plugin = PluginScanner(tmp_path / 'plugins').load_manifest(plugin_root / 'plugin.yaml') engine = create_async_engine('sqlite+aiosqlite:///:memory:') session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) - monkeypatch.setattr('plugins.core.lifecycle.migration.DataBaseConfig.db_type', 'mysql') + monkeypatch.setattr('plugins.core.lifecycle.migration.DataBaseConfig.default_source.db_type', 'mysql') try: async with session_maker() as session: diff --git a/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_seed.py b/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_seed.py index 8788326..961c088 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_seed.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_seed.py @@ -112,7 +112,7 @@ async def test_plugin_seed_runner_filters_seed_by_database_dialect( """, encoding='utf-8', ) - monkeypatch.setattr('plugins.core.lifecycle.seed.DataBaseConfig.db_type', 'mysql') + monkeypatch.setattr('plugins.core.lifecycle.seed.DataBaseConfig.default_source.db_type', 'mysql') discovered_plugin = PluginScanner(tmp_path / 'plugins').load_manifest(plugin_root / 'plugin.yaml') engine = create_async_engine('sqlite+aiosqlite:///:memory:') session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py index df27c18..a4bb614 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py @@ -1,5 +1,8 @@ +from contextlib import asynccontextmanager from pathlib import Path from subprocess import CompletedProcess +from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,9 +15,13 @@ BACKEND_ROOT = Path(__file__).resolve().parents[4] -def patch_startup_get_db(fake_get_db: object) -> object: - """patch 启动管理器方法实际使用的 get_db 全局引用。""" - return patch.dict(PluginRuntimeStartupManager.load_registry_from_database.__globals__, {'get_db': fake_get_db}) +def patch_startup_get_db(fake_get_db: Any) -> object: + """patch 启动管理器方法实际使用的默认数据源Session。""" + registry = SimpleNamespace(session=asynccontextmanager(fake_get_db)) + return patch.dict( + PluginRuntimeStartupManager.load_registry_from_database.__globals__, + {'DataSourceRegistry': registry}, + ) def patch_startup_global(name: str, value: object) -> object: @@ -445,10 +452,11 @@ async def test_run_plugin_install_scripts_runs_migrations_and_seeds() -> None: migration_session_context = MagicMock() migration_session_context.__aenter__ = AsyncMock(return_value=fake_migration_session) migration_session_context.__aexit__ = AsyncMock(return_value=None) - async_session_local = MagicMock(return_value=migration_session_context) + database_registry = MagicMock() + database_registry.session.return_value = migration_session_context with ( - patch_startup_global('AsyncSessionLocal', async_session_local), + patch_startup_global('DataSourceRegistry', database_registry), patch_startup_global('PluginMigrationRunner', runner_class), patch_startup_global('PluginSeedRunner', seed_runner_class), ): @@ -457,7 +465,7 @@ async def test_run_plugin_install_scripts_runs_migrations_and_seeds() -> None: runner_class.assert_called_once() assert runner_class.call_args.args[0] is discovered_plugin assert isinstance(runner_class.call_args.args[1], PluginStartupMigrationHistoryStore) - assert runner_class.call_args.args[1].async_session_local is async_session_local + assert runner_class.call_args.args[1].async_session_local is database_registry.session assert runner_class.call_args.kwargs['manage_execution_transaction'] is True migration_runner.run.assert_awaited_once_with(fake_migration_session) seed_runner_class.assert_called_once_with(discovered_plugin) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest.py b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest.py index 2fa63c6..3052813 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest.py @@ -217,7 +217,7 @@ def test_manifest_checker_accepts_satisfied_compatibility(tmp_path: Path) -> Non def test_manifest_checker_accepts_supported_database(monkeypatch: object) -> None: """校验当前数据库在插件支持列表内时不产生问题。""" - monkeypatch.setattr(manifest_module.DataBaseConfig, 'db_type', 'postgresql') + monkeypatch.setattr(manifest_module.DataBaseConfig.default_source, 'db_type', 'postgresql') manifest = PluginManifest.model_validate( { 'id': 'demo', @@ -302,7 +302,7 @@ def test_manifest_checker_reports_unsatisfied_compatibility(tmp_path: Path) -> N def test_manifest_checker_reports_unsupported_database(monkeypatch: object) -> None: """校验当前数据库不在插件支持列表内时产生 error。""" - monkeypatch.setattr(manifest_module.DataBaseConfig, 'db_type', 'postgresql') + monkeypatch.setattr(manifest_module.DataBaseConfig.default_source, 'db_type', 'postgresql') manifest = PluginManifest.model_validate( { 'id': 'demo', diff --git a/ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py b/ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py index aa35f96..ac5ba5a 100644 --- a/ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py +++ b/ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py @@ -43,24 +43,24 @@ ] EXPECTED_AI_VUE2_NPM_DEPENDENCIES = [ '@antv/infographic^0.2.13', - '@terrastruct/d2>=0.1.33', + '@terrastruct/d2==0.1.33', 'katex>=0.16.27', - 'markstream-vue2^0.0.50', + 'markstream-vue2==0.0.50', 'mermaid>=11.15.0', 'shiki^3.21.0', - 'stream-markdown>=0.0.16', - 'stream-monaco>=0.0.48', + 'stream-markdown==0.0.16', + 'stream-monaco==0.0.48', ] EXPECTED_AI_VUE3_NPM_DEPENDENCIES = [ '@antv/infographic^0.2.13', - '@terrastruct/d2>=0.1.33', + '@terrastruct/d2==0.1.33', 'katex>=0.16.27', - 'markstream-vue>=1.0.9-beta.2', + 'markstream-vue==1.0.9-beta.2', 'mermaid>=11.15.0', 'shiki^3.21.0', - 'stream-diffs>=0.0.2', - 'stream-markdown>=0.0.16', - 'stream-monaco>=0.0.48', + 'stream-diffs==0.0.2', + 'stream-markdown==0.0.16', + 'stream-monaco==0.0.48', ] EXPECTED_AI_VUE3_NPM_DEV_DEPENDENCIES = ['vite-plugin-monaco-editor-esm==2.0.2'] EXPECTED_DEFAULT_NUM_HISTORY_RUNS = 3 diff --git a/ruoyi-fastapi-backend/tests/scripts/test_migrate_legacy_files.py b/ruoyi-fastapi-backend/tests/scripts/test_migrate_legacy_files.py index 6ef0b07..c26345d 100644 --- a/ruoyi-fastapi-backend/tests/scripts/test_migrate_legacy_files.py +++ b/ruoyi-fastapi-backend/tests/scripts/test_migrate_legacy_files.py @@ -31,6 +31,11 @@ async def __aexit__(self, exc_type: type | None, exc_value: BaseException | None return None +def make_database_registry(session: SimpleNamespace) -> SimpleNamespace: + """构造使用测试会话的数据库注册表。""" + return SimpleNamespace(session=lambda: AsyncSessionContext(session)) + + def test_collect_legacy_files_filters_disallowed_extensions(tmp_path: Path) -> None: allowed_file = tmp_path / 'upload' / 'report.txt' allowed_file.parent.mkdir() @@ -82,7 +87,7 @@ def test_migrate_legacy_files_dry_run_performs_full_validation_without_commit(tm with ( patch.object(UploadConfig, 'UPLOAD_PATH', str(tmp_path)), - patch('scripts.migrate_legacy_files.AsyncSessionLocal', return_value=AsyncSessionContext(session)), + patch('scripts.migrate_legacy_files.DataSourceRegistry', make_database_registry(session)), patch.object(FileInfoDao, 'get_file_info_by_storage_key', new=AsyncMock(return_value=None)) as get_file_info, patch.object(FileInfoDao, 'add_file_info_dao', new_callable=AsyncMock) as add_file_info, ): @@ -100,7 +105,7 @@ def test_migrate_legacy_files_writes_file_info_model_and_commits(tmp_path: Path) with ( patch.object(UploadConfig, 'UPLOAD_PATH', str(tmp_path)), - patch('scripts.migrate_legacy_files.AsyncSessionLocal', return_value=AsyncSessionContext(session)), + patch('scripts.migrate_legacy_files.DataSourceRegistry', make_database_registry(session)), patch.object(FileInfoDao, 'get_file_info_by_storage_key', new=AsyncMock(return_value=None)), patch.object(FileInfoDao, 'add_file_info_dao', new_callable=AsyncMock) as add_file_info, ): @@ -125,7 +130,7 @@ def test_migrate_legacy_files_skips_existing_storage_location(tmp_path: Path) -> with ( patch.object(UploadConfig, 'UPLOAD_PATH', str(tmp_path)), - patch('scripts.migrate_legacy_files.AsyncSessionLocal', return_value=AsyncSessionContext(session)), + patch('scripts.migrate_legacy_files.DataSourceRegistry', make_database_registry(session)), patch.object( FileInfoDao, 'get_file_info_by_storage_key', @@ -147,7 +152,7 @@ def test_migrate_legacy_files_commits_by_batch(tmp_path: Path) -> None: with ( patch.object(UploadConfig, 'UPLOAD_PATH', str(tmp_path)), - patch('scripts.migrate_legacy_files.AsyncSessionLocal', return_value=AsyncSessionContext(session)), + patch('scripts.migrate_legacy_files.DataSourceRegistry', make_database_registry(session)), patch.object(FileInfoDao, 'get_file_info_by_storage_key', new=AsyncMock(return_value=None)), patch.object(FileInfoDao, 'add_file_info_dao', new_callable=AsyncMock) as add_file_info, ): diff --git a/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py b/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py index 8d0519f..64ee89d 100644 --- a/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py +++ b/ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py @@ -50,6 +50,7 @@ async def test_initialize_application_runtime_delegates_plugin_steps() -> None: fake_plugin_runtime.startup = AsyncMock() with ( + patch('server.DataSourceRegistry.initialize', new_callable=AsyncMock) as initialize_data_sources, patch('server.get_plugin_application_runtime', return_value=fake_plugin_runtime), patch('server.init_create_table', new_callable=AsyncMock) as init_create_table, patch('server.RedisUtil.check_redis_connection', new_callable=AsyncMock) as check_redis_connection, @@ -58,6 +59,7 @@ async def test_initialize_application_runtime_delegates_plugin_steps() -> None: patch('server._start_background_tasks', new_callable=AsyncMock) as start_background_tasks, ): await _initialize_application_runtime(fake_app, application_leader=True) + initialize_data_sources.assert_awaited_once_with(log_enabled=True) fake_plugin_runtime.prepare_metadata.assert_called_once_with(fake_app) init_create_table.assert_awaited_once_with( stage='platform', @@ -99,6 +101,7 @@ async def run_as_plugin_writer( fake_plugin_runtime.startup = AsyncMock(side_effect=run_as_plugin_writer) with ( + patch('server.DataSourceRegistry.initialize', new_callable=AsyncMock) as initialize_data_sources, patch('server.get_plugin_application_runtime', return_value=fake_plugin_runtime), patch('server.init_create_table', new_callable=AsyncMock) as init_create_table, patch('server.RedisUtil.check_redis_connection', new_callable=AsyncMock) as check_redis_connection, @@ -108,6 +111,7 @@ async def run_as_plugin_writer( ): await _initialize_application_runtime(fake_app, application_leader=False) + initialize_data_sources.assert_awaited_once_with(log_enabled=False) assert init_create_table.await_args_list == [ call(stage='platform', log_success_enabled=False), call(stage='plugin_entities', log_success_enabled=True), @@ -226,6 +230,56 @@ async def test_lifespan_non_leader_initialization_error_propagates_and_still_cle shutdown_runtime.assert_awaited_once_with(app) +@pytest.mark.asyncio +async def test_lifespan_skips_database_initialization_when_redis_creation_fails() -> None: + """校验Redis前置初始化失败时不创建数据库资源,并执行幂等清理。""" + app = SimpleNamespace(state=SimpleNamespace()) + database_registry = MagicMock() + database_registry.initialize = AsyncMock() + database_registry.dispose_all = AsyncMock() + fake_logger = MagicMock() + fake_logger.complete = AsyncMock() + + with ( + patch('server.DataSourceRegistry', database_registry), + patch( + 'server.RedisUtil.create_redis_pool', + new=AsyncMock(side_effect=RuntimeError('redis unavailable')), + ), + patch('server.logger', fake_logger), + pytest.raises(RuntimeError, match='redis unavailable'), + ): + async with lifespan(app): + pass + + database_registry.initialize.assert_not_awaited() + database_registry.dispose_all.assert_awaited_once_with() + fake_logger.complete.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_lifespan_database_initialization_failure_releases_redis_and_database_resources() -> None: + """校验Redis和租约创建后数据库初始化失败仍走统一关闭流程。""" + app = SimpleNamespace(state=SimpleNamespace()) + redis = MagicMock() + database_registry = MagicMock() + database_registry.initialize = AsyncMock(side_effect=RuntimeError('database unavailable')) + + with ( + patch('server.RedisUtil.create_redis_pool', new=AsyncMock(return_value=redis)), + patch('server.DataSourceRegistry', database_registry), + patch('server.SchedulerUtil.get_application_lock_owner_token', return_value='owner-db-failure'), + patch('server.StartupUtil.acquire_application_leader', new=AsyncMock(return_value=False)), + patch('server._shutdown_application_runtime', new_callable=AsyncMock) as shutdown_runtime, + pytest.raises(RuntimeError, match='database unavailable'), + ): + async with lifespan(app): + pass + + database_registry.initialize.assert_awaited_once_with(log_enabled=False) + shutdown_runtime.assert_awaited_once_with(app) + + @pytest.mark.asyncio async def test_shutdown_application_runtime_preserves_cleanup_order() -> None: """校验插件关闭先执行,随后按Scheduler、Redis、数据库顺序释放资源。""" @@ -264,7 +318,7 @@ async def record_log_complete() -> None: 'server.RedisUtil.close_redis_pool', new=AsyncMock(side_effect=record_redis_shutdown), ), - patch('server.close_async_engine', new=AsyncMock(side_effect=record_database_shutdown)), + patch('server.DataSourceRegistry.dispose_all', new=AsyncMock(side_effect=record_database_shutdown)), patch('server.logger.complete', new=AsyncMock(side_effect=record_log_complete)), ): await _shutdown_application_runtime(app) @@ -302,10 +356,10 @@ async def test_stop_background_tasks_closes_redis_and_database_when_scheduler_cl new=AsyncMock(side_effect=RuntimeError('scheduler close failed')), ), patch('server.RedisUtil.close_redis_pool', new_callable=AsyncMock) as close_redis_pool, - patch('server.close_async_engine', new_callable=AsyncMock) as close_async_engine, + patch('server.DataSourceRegistry.dispose_all', new_callable=AsyncMock) as dispose_all, pytest.raises(RuntimeError, match='scheduler close failed'), ): await _stop_background_tasks(app) close_redis_pool.assert_awaited_once_with(app) - close_async_engine.assert_awaited_once_with() + dispose_all.assert_awaited_once_with() diff --git a/ruoyi-fastapi-backend/tests/utils/test_template_util.py b/ruoyi-fastapi-backend/tests/utils/test_template_util.py index cd8cb91..0126b25 100644 --- a/ruoyi-fastapi-backend/tests/utils/test_template_util.py +++ b/ruoyi-fastapi-backend/tests/utils/test_template_util.py @@ -1,7 +1,9 @@ import json +from types import SimpleNamespace import pytest +from config import database from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel, GenTableParamsModel from module_generator.service.gen_service import GenTableService from utils.template_util import TemplateInitializer, TemplateUtils @@ -151,3 +153,97 @@ async def test_set_table_from_options_restores_view_flag() -> None: result = await GenTableService.set_table_from_options(gen_table) assert result.view is True + + +def test_secondary_source_templates_use_named_dependency_and_isolated_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gen_table = _gen_table(gen_view=False) + gen_table.data_source_name = 'reporting' + monkeypatch.setattr( + type(database.DataBaseConfig), + 'get_source', + lambda _self, _name=None: SimpleNamespace(db_type='postgresql'), + ) + + context = TemplateUtils.prepare_context(gen_table) + env = TemplateInitializer.init_jinja2() + controller = env.get_template('python/controller.py.jinja2').render(**context) + entity = env.get_template('python/do.py.jinja2').render(**context) + + assert "DBSessionDependency('reporting')" in controller + assert 'from common.aspect.db_session import DBSessionDependency' in controller + assert "DataSourceBase = get_data_source_base('reporting')" in entity + assert 'class GenItem(DataSourceBase):' in entity + assert 'from config.database import Base' not in entity + compile(entity, '', 'exec') + + +def test_sub_table_entity_template_uses_required_nullable_semantics() -> None: + gen_table = _gen_table(gen_view=False, tpl_category='sub') + gen_table.sub_table_name = 'gen_item_detail' + gen_table.sub_table_fk_name = 'item_id' + gen_table.sub_table = GenTableModel( + tableName='gen_item_detail', + tableComment='生成测试明细', + className='GenItemDetail', + columns=[ + GenTableColumnModel( + columnName='required_value', + columnComment='必填值', + columnType='varchar(100)', + pythonType='str', + pythonField='requiredValue', + isRequired='1', + ), + GenTableColumnModel( + columnName='optional_value', + columnComment='可选值', + columnType='varchar(100)', + pythonType='str', + pythonField='optionalValue', + isRequired='0', + ), + ], + ) + + context = TemplateUtils.prepare_context(gen_table) + entity = TemplateInitializer.init_jinja2().get_template('python/do.py.jinja2').render(**context) + + assert "required_value = Column(String(100), nullable=False, comment='必填值')" in entity + assert "optional_value = Column(String(100), nullable=True, comment='可选值')" in entity + + +def test_named_data_source_bases_are_cached_and_isolated(monkeypatch: pytest.MonkeyPatch) -> None: + database.get_data_source_base.cache_clear() + monkeypatch.setattr(type(database.DataBaseConfig), 'get_source', lambda _self, _name: object()) + + reporting_base = database.get_data_source_base('reporting') + archive_base = database.get_data_source_base('archive') + + assert reporting_base is database.get_data_source_base('reporting') + assert reporting_base is not archive_base + assert reporting_base.metadata is not archive_base.metadata + + +@pytest.mark.parametrize( + ('db_type', 'column_type', 'expected'), + [ + ('postgresql', 'JSONB', 'JSONB'), + ('postgresql', 'INET', 'INET'), + ('postgresql', 'ARRAY', 'ARRAY'), + ('postgresql', 'VARCHAR(64)', 'String(64)'), + ('mysql', 'DECIMAL(10, 2)', 'DECIMAL(10, 2)'), + ('mysql', 'LONGBLOB', 'LargeBinary'), + ], +) +def test_sqlalchemy_type_mapping_uses_target_source( + monkeypatch: pytest.MonkeyPatch, db_type: str, column_type: str, expected: str +) -> None: + monkeypatch.setattr( + type(database.DataBaseConfig), + 'get_source', + lambda _self, _name=None: SimpleNamespace(db_type=db_type), + ) + + assert TemplateUtils.get_sqlalchemy_type(column_type) == expected diff --git a/ruoyi-fastapi-backend/utils/gen_util.py b/ruoyi-fastapi-backend/utils/gen_util.py index dc8e881..33a4824 100644 --- a/ruoyi-fastapi-backend/utils/gen_util.py +++ b/ruoyi-fastapi-backend/utils/gen_util.py @@ -2,7 +2,7 @@ from datetime import datetime from common.constant import GenConstant -from config.env import GenConfig +from config.env import DataBaseConfig, GenConfig from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel from utils.string_util import StringUtil @@ -41,33 +41,35 @@ def init_column_field(cls, column: GenTableColumnModel, table: GenTableModel) -> param table: 业务表对象 :return: """ - data_type = cls.get_db_type(column.column_type) + data_type = cls.get_db_type(column.column_type).lower() + source_config = DataBaseConfig.get_source(table.data_source_name) + db_type = source_config.db_type + python_mapping = GenConstant.DB_TO_PYTHON_TYPE_MAPPING[db_type] + string_types = GenConstant.COLUMNTYPE_STR[db_type] + text_types = GenConstant.COLUMNTYPE_TEXT[db_type] + time_types = GenConstant.COLUMNTYPE_TIME[db_type] + number_types = GenConstant.COLUMNTYPE_NUMBER[db_type] column_name = column.column_name column.table_id = table.table_id column.create_by = table.create_by # 设置Python字段名 column.python_field = cls.to_camel_case(column_name) # 设置默认类型 - column.python_type = StringUtil.get_mapping_value_by_key_ignore_case( - GenConstant.DB_TO_PYTHON_TYPE_MAPPING, data_type - ) + column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(python_mapping, data_type) or 'str' column.query_type = GenConstant.QUERY_EQ - if cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains( - GenConstant.COLUMNTYPE_TEXT, data_type - ): + if data_type in string_types or data_type in text_types: # 字符串长度超过500设置为文本域 column_length = cls.get_column_length(column.column_type) html_type = ( GenConstant.HTML_TEXTAREA - if column_length >= cls.TEXTAREA_COLUMN_LENGTH - or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type) + if column_length >= cls.TEXTAREA_COLUMN_LENGTH or data_type in text_types else GenConstant.HTML_INPUT ) column.html_type = html_type - elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type): + elif data_type in time_types: column.html_type = GenConstant.HTML_DATETIME - elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type): + elif data_type in number_types: column.html_type = GenConstant.HTML_INPUT # 插入字段(默认所有字段都需要插入) diff --git a/ruoyi-fastapi-backend/utils/template_util.py b/ruoyi-fastapi-backend/utils/template_util.py index 12ad67d..21f5653 100644 --- a/ruoyi-fastapi-backend/utils/template_util.py +++ b/ruoyi-fastapi-backend/utils/template_util.py @@ -73,6 +73,9 @@ def prepare_context(cls, gen_table: GenTableModel) -> dict[str, Any]: tpl_category = gen_table.tpl_category function_name = gen_table.function_name + source_name = gen_table.data_source_name or DataBaseConfig.db_default_source + source_config = DataBaseConfig.get_source(source_name) + default_source_name = DataBaseConfig.db_default_source context = { 'tplCategory': tpl_category, 'tableName': gen_table.table_name, @@ -95,7 +98,14 @@ def prepare_context(cls, gen_table: GenTableModel) -> dict[str, Any]: 'columns': gen_table.columns, 'table': gen_table, 'dicts': cls.get_dicts(gen_table), - 'dbType': DataBaseConfig.db_type, + 'dbType': source_config.db_type, + 'dataSourceName': source_name, + 'defaultDataSourceName': default_source_name, + 'dbSessionDependency': ( + f"DBSessionDependency('{source_name}')" + if source_name != default_source_name + else 'DBSessionDependency()' + ), 'column_not_add_show': GenConstant.COLUMNNAME_NOT_ADD_SHOW, 'column_not_edit_show': GenConstant.COLUMNNAME_NOT_EDIT_SHOW, } @@ -288,20 +298,20 @@ def get_do_import_list(cls, gen_table: GenTableModel) -> list[str]: import_list = set() import_list.add('from sqlalchemy import Column') for column in columns: - data_type = cls.get_db_type(column.column_type) - if data_type in GenConstant.COLUMNTYPE_GEOMETRY: + sqlalchemy_type = cls.get_sqlalchemy_type(column.column_type, gen_table.data_source_name) + if sqlalchemy_type == 'Geometry': import_list.add('from geoalchemy2 import Geometry') - import_list.add( - f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}' - ) + else: + import_list.add(f'from sqlalchemy import {sqlalchemy_type.split("(", 1)[0]}') if gen_table.sub: import_list.add('from sqlalchemy import ForeignKey') sub_columns = gen_table.sub_table.columns or [] for sub_column in sub_columns: - data_type = cls.get_db_type(sub_column.column_type) - import_list.add( - f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}' - ) + sqlalchemy_type = cls.get_sqlalchemy_type(sub_column.column_type, gen_table.data_source_name) + if sqlalchemy_type == 'Geometry': + import_list.add('from geoalchemy2 import Geometry') + else: + import_list.add(f'from sqlalchemy import {sqlalchemy_type.split("(", 1)[0]}') return cls.merge_same_imports(list(import_list), 'from sqlalchemy import') @classmethod @@ -491,30 +501,19 @@ def to_camel_case(cls, text: str) -> str: return parts[0] + ''.join(word.capitalize() for word in parts[1:]) @classmethod - def get_sqlalchemy_type(cls, column_type: str) -> str: + def get_sqlalchemy_type(cls, column_type: str, source_name: str | None = None) -> str: """ 获取SQLAlchemy类型 :param column_type: 列类型 + :param source_name: 数据源名称 :return: SQLAlchemy类型 """ - if '(' in column_type: - column_type_list = column_type.split('(') - if column_type_list[0] in GenConstant.COLUMNTYPE_STR: - sqlalchemy_type = ( - StringUtil.get_mapping_value_by_key_ignore_case( - GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0] - ) - + '(' - + column_type_list[1] - ) - else: - sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case( - GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0] - ) - else: - sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case( - GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type - ) - + source_config = DataBaseConfig.get_source(source_name) + normalized = column_type.lower().strip() + base = normalized.split('(', 1)[0].removesuffix(' unsigned').strip() + type_mapping = GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING[source_config.db_type] + sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(type_mapping, base) or 'String' + if '(' in normalized and sqlalchemy_type in {'String', 'CHAR', 'Numeric', 'DECIMAL'}: + return f'{sqlalchemy_type}({normalized.split("(", 1)[1]}' return sqlalchemy_type diff --git a/ruoyi-fastapi-frontend/package.json b/ruoyi-fastapi-frontend/package.json index f2d630e..44f92c5 100644 --- a/ruoyi-fastapi-frontend/package.json +++ b/ruoyi-fastapi-frontend/package.json @@ -29,7 +29,7 @@ "@antv/g2plot": "^2.4.31", "@antv/infographic": "^0.2.13", "@riophae/vue-treeselect": "0.4.0", - "@terrastruct/d2": ">=0.1.33", + "@terrastruct/d2": "0.1.33", "ant-design-vue": "^1.7.8", "axios": "0.30.3", "clipboard": "2.0.8", @@ -43,15 +43,15 @@ "js-cookie": "3.0.1", "jsencrypt": "3.0.0-rc.1", "katex": ">=0.16.27", - "markstream-vue2": "^0.0.50", + "markstream-vue2": "0.0.50", "mermaid": ">=11.15.0", "nprogress": "0.2.0", "quill": "2.0.2", "screenfull": "5.0.2", "shiki": "^3.21.0", "sortablejs": "1.10.2", - "stream-markdown": ">=0.0.16", - "stream-monaco": ">=0.0.48", + "stream-markdown": "0.0.16", + "stream-monaco": "0.0.48", "uuid": "^8.3.2", "viser-vue": "^2.4.8", "vue": "2.7.16", diff --git a/ruoyi-fastapi-frontend/src/api/tool/gen.js b/ruoyi-fastapi-frontend/src/api/tool/gen.js index 2075677..db5a5f7 100644 --- a/ruoyi-fastapi-frontend/src/api/tool/gen.js +++ b/ruoyi-fastapi-frontend/src/api/tool/gen.js @@ -1,5 +1,13 @@ import request from '@/utils/request' +// 查询代码生成数据源选项 +export function listDataSources() { + return request({ + url: '/tool/gen/dataSources', + method: 'get' + }) +} + // 查询生成表数据 export function listTable(query) { return request({ @@ -69,17 +77,19 @@ export function delTable(tableId) { } // 生成代码(自定义路径) -export function genCode(tableName) { +export function genCode(tableName, dataSourceName) { return request({ url: '/tool/gen/genCode/' + tableName, - method: 'get' + method: 'get', + params: { dataSourceName } }) } // 同步数据库 -export function synchDb(tableName) { +export function synchDb(tableName, dataSourceName) { return request({ url: '/tool/gen/synchDb/' + tableName, - method: 'get' + method: 'get', + params: { dataSourceName } }) } diff --git a/ruoyi-fastapi-frontend/src/views/tool/gen/createTable.vue b/ruoyi-fastapi-frontend/src/views/tool/gen/createTable.vue index f914b5d..1f10601 100644 --- a/ruoyi-fastapi-frontend/src/views/tool/gen/createTable.vue +++ b/ruoyi-fastapi-frontend/src/views/tool/gen/createTable.vue @@ -1,6 +1,23 @@