1342 lines
49 KiB
Python
1342 lines
49 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Data Source Service - 数据源服务
|
||
提供数据源执行、缓存、转换等核心功能(异步版本)
|
||
|
||
数据权限:
|
||
- 使用 get_list_with_data_scope() 自动应用数据权限
|
||
- 支持本人、本部门、本部门及下级、全部等数据范围
|
||
"""
|
||
import hashlib
|
||
import logging
|
||
import re
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import httpx
|
||
from sqlalchemy import select, func, and_
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from utils.redis import RedisClient
|
||
from core.data_source.model import DataSource
|
||
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 资源类型(用于数据权限配置)
|
||
RESOURCE_TYPE = "data_source"
|
||
RESOURCE_DISPLAY_NAME = "数据源管理"
|
||
|
||
|
||
class DataSourceService:
|
||
"""数据源服务类"""
|
||
|
||
# SQL 危险关键词(禁止执行)
|
||
DANGEROUS_KEYWORDS = [
|
||
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'TRUNCATE',
|
||
'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE'
|
||
]
|
||
|
||
# 数据量限制
|
||
MAX_ROWS_EXECUTE = 1000 # 正常执行最多返回 1000 条
|
||
MAX_ROWS_TEST = 100 # 测试最多返回 100 条
|
||
|
||
# ==================== CRUD 方法 ====================
|
||
|
||
@classmethod
|
||
async def get_list(
|
||
cls,
|
||
db: AsyncSession,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
application_id: str = None,
|
||
name: str = None,
|
||
code: str = None,
|
||
source_type: str = None,
|
||
status: bool = None,
|
||
) -> Tuple[List[DataSource], int]:
|
||
"""获取数据源列表"""
|
||
stmt = select(DataSource).where(DataSource.is_deleted == False)
|
||
|
||
# 应用过滤
|
||
if application_id:
|
||
stmt = stmt.where(DataSource.application_id == application_id)
|
||
else:
|
||
stmt = stmt.where(DataSource.application_id.is_(None))
|
||
|
||
if name:
|
||
stmt = stmt.where(DataSource.name.contains(name))
|
||
if code:
|
||
stmt = stmt.where(DataSource.code.contains(code))
|
||
if source_type:
|
||
stmt = stmt.where(DataSource.source_type == source_type)
|
||
if status is not None:
|
||
stmt = stmt.where(DataSource.status == status)
|
||
|
||
# 计算总数
|
||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||
total_result = await db.execute(count_stmt)
|
||
total = total_result.scalar() or 0
|
||
|
||
# 分页
|
||
stmt = stmt.order_by(DataSource.sort.desc(), DataSource.sys_create_datetime.desc())
|
||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||
|
||
result = await db.execute(stmt)
|
||
items = list(result.scalars().all())
|
||
|
||
return items, total
|
||
|
||
@classmethod
|
||
async def get_list_with_data_scope(
|
||
cls,
|
||
db: AsyncSession,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
application_id: str = None,
|
||
name: str = None,
|
||
code: str = None,
|
||
source_type: str = None,
|
||
status: bool = None,
|
||
) -> Tuple[List[DataSource], int]:
|
||
"""
|
||
获取数据源列表(带数据权限过滤)
|
||
|
||
自动从上下文获取当前用户信息,应用数据权限过滤
|
||
"""
|
||
conditions = [DataSource.is_deleted == False]
|
||
|
||
# 应用过滤
|
||
if application_id:
|
||
conditions.append(DataSource.application_id == application_id)
|
||
else:
|
||
conditions.append(DataSource.application_id.is_(None))
|
||
|
||
if name:
|
||
conditions.append(DataSource.name.contains(name))
|
||
if code:
|
||
conditions.append(DataSource.code.contains(code))
|
||
if source_type:
|
||
conditions.append(DataSource.source_type == source_type)
|
||
if status is not None:
|
||
conditions.append(DataSource.status == status)
|
||
|
||
# 获取数据权限过滤条件并应用
|
||
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
|
||
scope_conditions = apply_data_scope_to_conditions(DataSource, data_scope_filter)
|
||
conditions.extend(scope_conditions)
|
||
|
||
# 计算总数
|
||
count_stmt = select(func.count()).select_from(
|
||
select(DataSource).where(and_(*conditions)).subquery()
|
||
)
|
||
total_result = await db.execute(count_stmt)
|
||
total = total_result.scalar() or 0
|
||
|
||
# 分页
|
||
stmt = select(DataSource).where(and_(*conditions))
|
||
stmt = stmt.order_by(DataSource.sort.desc(), DataSource.sys_create_datetime.desc())
|
||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||
|
||
result = await db.execute(stmt)
|
||
items = list(result.scalars().all())
|
||
|
||
return items, total
|
||
|
||
@classmethod
|
||
async def get_all(cls, db: AsyncSession, application_id: str = None) -> List[DataSource]:
|
||
"""获取所有启用的数据源"""
|
||
conditions = [
|
||
DataSource.is_deleted == False,
|
||
DataSource.status == True,
|
||
]
|
||
if application_id:
|
||
conditions.append(DataSource.application_id == application_id)
|
||
else:
|
||
conditions.append(DataSource.application_id.is_(None))
|
||
|
||
stmt = select(DataSource).where(*conditions).order_by(DataSource.sort.desc())
|
||
result = await db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
|
||
@classmethod
|
||
async def get_by_id(cls, db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||
"""根据ID获取数据源"""
|
||
stmt = select(DataSource).where(
|
||
DataSource.id == source_id,
|
||
DataSource.is_deleted == False,
|
||
)
|
||
result = await db.execute(stmt)
|
||
return result.scalar_one_or_none()
|
||
|
||
@classmethod
|
||
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[DataSource]:
|
||
"""根据编码获取数据源"""
|
||
stmt = select(DataSource).where(
|
||
DataSource.code == code,
|
||
DataSource.is_deleted == False,
|
||
)
|
||
result = await db.execute(stmt)
|
||
return result.scalar_one_or_none()
|
||
|
||
@classmethod
|
||
async def create(cls, db: AsyncSession, data: dict) -> DataSource:
|
||
"""创建数据源"""
|
||
from utils.context import get_current_user_info_from_context
|
||
|
||
source = DataSource(**data)
|
||
|
||
# 自动填充创建人和部门
|
||
user_info = get_current_user_info_from_context()
|
||
if user_info:
|
||
if not source.sys_creator_id:
|
||
source.sys_creator_id = user_info.get('user_id')
|
||
if not source.sys_dept_id and user_info.get('dept_id'):
|
||
source.sys_dept_id = user_info.get('dept_id')
|
||
|
||
db.add(source)
|
||
await db.flush()
|
||
await db.refresh(source)
|
||
return source
|
||
|
||
@classmethod
|
||
async def update(cls, db: AsyncSession, source_id: str, data: dict) -> Optional[DataSource]:
|
||
"""更新数据源"""
|
||
source = await cls.get_by_id(db, source_id)
|
||
if not source:
|
||
return None
|
||
|
||
old_code = source.code
|
||
for key, value in data.items():
|
||
if value is not None and hasattr(source, key):
|
||
setattr(source, key, value)
|
||
|
||
db.add(source)
|
||
await db.flush()
|
||
await db.refresh(source)
|
||
|
||
# 清除缓存
|
||
await cls.clear_cache(old_code)
|
||
if source.code != old_code:
|
||
await cls.clear_cache(source.code)
|
||
|
||
return source
|
||
|
||
@classmethod
|
||
async def delete(cls, db: AsyncSession, source_id: str) -> bool:
|
||
"""删除数据源(软删除)"""
|
||
source = await cls.get_by_id(db, source_id)
|
||
if not source:
|
||
return False
|
||
|
||
source.is_deleted = True
|
||
db.add(source)
|
||
await db.flush()
|
||
|
||
# 清除缓存
|
||
await cls.clear_cache(source.code)
|
||
return True
|
||
|
||
@classmethod
|
||
async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool:
|
||
"""检查编码是否存在"""
|
||
stmt = select(DataSource).where(DataSource.code == code)
|
||
if exclude_id:
|
||
stmt = stmt.where(DataSource.id != exclude_id)
|
||
result = await db.execute(stmt)
|
||
return result.scalar_one_or_none() is not None
|
||
|
||
@classmethod
|
||
async def copy(cls, db: AsyncSession, source_id: str, new_code: str, new_name: str = None) -> Optional[DataSource]:
|
||
"""复制数据源"""
|
||
source = await cls.get_by_id(db, source_id)
|
||
if not source:
|
||
return None
|
||
|
||
# 创建副本
|
||
new_source = DataSource(
|
||
name=new_name or f"{source.name}(副本)",
|
||
code=new_code,
|
||
source_type=source.source_type,
|
||
description=source.description,
|
||
status=source.status,
|
||
api_url=source.api_url,
|
||
api_method=source.api_method,
|
||
api_headers=source.api_headers,
|
||
api_query_params=source.api_query_params,
|
||
api_body_type=source.api_body_type,
|
||
api_body=source.api_body,
|
||
api_content_type=source.api_content_type,
|
||
api_timeout=source.api_timeout,
|
||
api_data_path=source.api_data_path,
|
||
api_auth_type=source.api_auth_type,
|
||
api_auth_config=source.api_auth_config,
|
||
api_retry_count=source.api_retry_count,
|
||
api_retry_interval=source.api_retry_interval,
|
||
api_success_condition=source.api_success_condition,
|
||
api_proxy=source.api_proxy,
|
||
api_follow_redirects=source.api_follow_redirects,
|
||
api_verify_ssl=source.api_verify_ssl,
|
||
sql_content=source.sql_content,
|
||
db_connection=source.db_connection,
|
||
static_data=source.static_data,
|
||
params=source.params,
|
||
result_type=source.result_type,
|
||
tree_config=source.tree_config,
|
||
field_mapping=source.field_mapping,
|
||
chart_config=source.chart_config,
|
||
cache_enabled=source.cache_enabled,
|
||
cache_ttl=source.cache_ttl,
|
||
)
|
||
db.add(new_source)
|
||
await db.flush()
|
||
await db.refresh(new_source)
|
||
return new_source
|
||
|
||
# ==================== 执行方法 ====================
|
||
|
||
@classmethod
|
||
async def execute(cls, db: AsyncSession, code: str, params: Dict[str, Any] = None) -> Any:
|
||
"""根据编码执行数据源"""
|
||
source = await cls.get_by_code(db, code)
|
||
if not source or not source.status:
|
||
raise ValueError(f"数据源不存在或已禁用: {code}")
|
||
return await cls.execute_source(db, source, params)
|
||
|
||
@classmethod
|
||
async def execute_by_id(
|
||
cls,
|
||
db: AsyncSession,
|
||
source_id: str,
|
||
params: Dict[str, Any] = None,
|
||
max_rows: int = None,
|
||
) -> Any:
|
||
"""根据ID执行数据源"""
|
||
source = await cls.get_by_id(db, source_id)
|
||
if not source or not source.status:
|
||
raise ValueError("数据源不存在或已禁用")
|
||
return await cls.execute_source(db, source, params, max_rows=max_rows)
|
||
|
||
@classmethod
|
||
async def execute_source(
|
||
cls,
|
||
db: AsyncSession,
|
||
source: DataSource,
|
||
params: Dict[str, Any] = None,
|
||
max_rows: int = None,
|
||
) -> Any:
|
||
"""执行数据源对象"""
|
||
params = params or {}
|
||
row_limit = max_rows if max_rows and max_rows > 0 else cls.MAX_ROWS_EXECUTE
|
||
|
||
# 合并默认参数
|
||
final_params = cls._merge_params(source.params or [], params)
|
||
|
||
# 检查缓存
|
||
if source.cache_enabled:
|
||
cache_key = cls._get_cache_key(source.code, final_params)
|
||
cached = await cls._get_cache(cache_key)
|
||
if cached is not None:
|
||
logger.debug(f"数据源 {source.code} 命中缓存")
|
||
return cached
|
||
|
||
# 根据类型执行
|
||
if source.source_type == 'sql':
|
||
result = await cls._execute_sql(db, source, final_params)
|
||
elif source.source_type == 'api':
|
||
result = await cls._execute_api(source, final_params)
|
||
else:
|
||
result = source.static_data or []
|
||
|
||
# 字段映射
|
||
if source.field_mapping:
|
||
result = cls._apply_field_mapping(result, source.field_mapping)
|
||
|
||
# 结果转换
|
||
result = cls._transform_result(result, source)
|
||
|
||
# 限制返回数据量
|
||
if isinstance(result, list) and len(result) > row_limit:
|
||
logger.warning(f"数据源 {source.code} 返回数据超过限制,截取前 {row_limit} 条")
|
||
result = result[:row_limit]
|
||
|
||
# 写入缓存
|
||
if source.cache_enabled and source.cache_ttl > 0:
|
||
cache_key = cls._get_cache_key(source.code, final_params)
|
||
await cls._set_cache(cache_key, result, source.cache_ttl)
|
||
logger.debug(f"数据源 {source.code} 结果已缓存 {source.cache_ttl}s")
|
||
|
||
return result
|
||
|
||
@classmethod
|
||
async def execute_temp(cls, db: AsyncSession, config: Dict[str, Any], params: Dict[str, Any] = None) -> Any:
|
||
"""执行临时配置(用于测试/预览)"""
|
||
params = params or {}
|
||
|
||
# 合并默认参数
|
||
params_def = config.get('params_def', [])
|
||
final_params = cls._merge_params(params_def, params)
|
||
|
||
source_type = config.get('source_type', 'static')
|
||
|
||
# 根据类型执行
|
||
if source_type == 'sql':
|
||
result = await cls._execute_sql_temp(db, config, final_params)
|
||
elif source_type == 'api':
|
||
result = await cls._execute_api_temp(config, final_params)
|
||
else:
|
||
result = config.get('static_data', [])
|
||
|
||
# 字段映射
|
||
field_mapping = config.get('field_mapping', {})
|
||
if field_mapping:
|
||
result = cls._apply_field_mapping(result, field_mapping)
|
||
|
||
# 结果转换
|
||
result = cls._transform_result_temp(result, config)
|
||
|
||
# 测试时限制返回数据量
|
||
if isinstance(result, list) and len(result) > cls.MAX_ROWS_TEST:
|
||
result = result[:cls.MAX_ROWS_TEST]
|
||
|
||
return result
|
||
|
||
# ==================== SQL 执行 ====================
|
||
|
||
@classmethod
|
||
async def _execute_sql(cls, db: AsyncSession, source: DataSource, params: Dict[str, Any]) -> List[Dict]:
|
||
"""执行 SQL 查询(支持平台库与第三方连接)"""
|
||
sql = (source.sql_content or '').strip()
|
||
db_connection = (source.db_connection or 'default').strip() or 'default'
|
||
return await cls._execute_sql_internal(db, sql, params, db_connection=db_connection)
|
||
|
||
@classmethod
|
||
async def _execute_sql_temp(cls, db: AsyncSession, config: Dict[str, Any], params: Dict[str, Any]) -> List[Dict]:
|
||
"""执行临时 SQL 查询"""
|
||
sql = config.get('sql_content', '').strip()
|
||
db_connection = (config.get('db_connection') or 'default').strip() or 'default'
|
||
return await cls._execute_sql_internal(db, sql, params, db_connection=db_connection)
|
||
|
||
@classmethod
|
||
def _expand_date_params(cls, sql: str, params: Dict[str, Any]) -> tuple:
|
||
"""将 date 类型参数的 = 比较自动展开为范围查询,使 date 参数可匹配 datetime/timestamp 字段"""
|
||
from datetime import date, datetime as dt, timedelta
|
||
date_param_names = [
|
||
k for k, v in params.items()
|
||
if isinstance(v, date) and not isinstance(v, dt)
|
||
]
|
||
if not date_param_names:
|
||
return sql, params
|
||
|
||
new_sql = sql
|
||
new_params = dict(params)
|
||
for name in date_param_names:
|
||
pattern = re.compile(
|
||
r'(\w+(?:\.\w+)?)'
|
||
r'\s*=\s*'
|
||
r':' + re.escape(name) + r'\b',
|
||
re.IGNORECASE,
|
||
)
|
||
match = pattern.search(new_sql)
|
||
if match:
|
||
col = match.group(1)
|
||
start_key = f'{name}__start'
|
||
end_key = f'{name}__end'
|
||
replacement = f'{col} >= :{start_key} AND {col} < :{end_key}'
|
||
new_sql = new_sql[:match.start()] + replacement + new_sql[match.end():]
|
||
d = new_params.pop(name)
|
||
new_params[start_key] = dt(d.year, d.month, d.day, 0, 0, 0)
|
||
new_params[end_key] = dt(d.year, d.month, d.day, 0, 0, 0) + timedelta(days=1)
|
||
|
||
return new_sql, new_params
|
||
|
||
@classmethod
|
||
def _compile_sql_with_params(cls, sql: str, params: Dict[str, Any], db_type: str) -> str:
|
||
"""将 :param 绑定为字面量,供外部数据库驱动执行"""
|
||
from utils.sql_param_compile import compile_sql_with_named_params
|
||
|
||
return compile_sql_with_named_params(sql, params, db_type)
|
||
|
||
@classmethod
|
||
async def _execute_sql_internal(
|
||
cls,
|
||
db: AsyncSession,
|
||
sql: str,
|
||
params: Dict[str, Any],
|
||
db_connection: str = 'default',
|
||
) -> List[Dict]:
|
||
"""内部 SQL 执行方法"""
|
||
if not sql:
|
||
return []
|
||
|
||
# 安全检查:只允许 SELECT
|
||
sql_upper = sql.upper().strip()
|
||
if not sql_upper.startswith('SELECT') and not sql_upper.startswith('WITH'):
|
||
raise ValueError('只允许 SELECT 或 WITH 查询')
|
||
|
||
# 禁止危险关键词
|
||
for keyword in cls.DANGEROUS_KEYWORDS:
|
||
pattern = r'\b' + keyword + r'\b'
|
||
if re.search(pattern, sql_upper):
|
||
raise ValueError(f'SQL 中不允许使用 {keyword}')
|
||
|
||
# date 参数自动展开为范围查询,兼容 datetime/timestamp 字段
|
||
sql, params = cls._expand_date_params(sql, params)
|
||
|
||
try:
|
||
if db_connection == 'default':
|
||
from sqlalchemy import text
|
||
|
||
result = await db.execute(text(sql), params)
|
||
rows = result.fetchall()
|
||
columns = result.keys()
|
||
return [dict(zip(columns, row)) for row in rows]
|
||
|
||
from core.database_connection.resolver import ConnectionResolver
|
||
from core.database_manager.service import AsyncDatabaseManagerService
|
||
|
||
info = await ConnectionResolver.resolve(db_connection, db)
|
||
service = await AsyncDatabaseManagerService.create(db_connection, db)
|
||
compiled_sql = cls._compile_sql_with_params(sql, params, info.db_type)
|
||
result_data = await service.execute_sql(compiled_sql, is_query=True)
|
||
if not result_data.get('success'):
|
||
raise ValueError(result_data.get('message', 'SQL 执行失败'))
|
||
return result_data.get('rows') or []
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"SQL 执行失败: {str(e)}")
|
||
raise ValueError(f"SQL 执行失败: {str(e)}")
|
||
|
||
# ==================== API 执行 ====================
|
||
|
||
@classmethod
|
||
async def _execute_api(cls, source: DataSource, params: Dict[str, Any]) -> Any:
|
||
"""执行 API 请求"""
|
||
return await cls._execute_api_internal(
|
||
url=source.api_url or '',
|
||
method=source.api_method or 'GET',
|
||
headers=source.api_headers or {},
|
||
query_params=source.api_query_params or [],
|
||
body_type=source.api_body_type or 'none',
|
||
body=source.api_body or {},
|
||
content_type=source.api_content_type or '',
|
||
timeout=source.api_timeout or 30,
|
||
data_path=source.api_data_path or '',
|
||
auth_type=source.api_auth_type or 'none',
|
||
auth_config=source.api_auth_config or {},
|
||
retry_count=source.api_retry_count or 0,
|
||
retry_interval=source.api_retry_interval or 1,
|
||
success_condition=source.api_success_condition or {},
|
||
proxy=source.api_proxy or '',
|
||
follow_redirects=source.api_follow_redirects if source.api_follow_redirects is not None else True,
|
||
verify_ssl=source.api_verify_ssl if source.api_verify_ssl is not None else True,
|
||
params=params
|
||
)
|
||
|
||
@classmethod
|
||
async def _execute_api_temp(cls, config: Dict[str, Any], params: Dict[str, Any]) -> Any:
|
||
"""执行临时 API 请求"""
|
||
return await cls._execute_api_internal(
|
||
url=config.get('api_url', ''),
|
||
method=config.get('api_method', 'GET'),
|
||
headers=config.get('api_headers', {}),
|
||
query_params=config.get('api_query_params', []),
|
||
body_type=config.get('api_body_type', 'none'),
|
||
body=config.get('api_body', {}),
|
||
content_type=config.get('api_content_type', ''),
|
||
timeout=config.get('api_timeout', 30),
|
||
data_path=config.get('api_data_path', ''),
|
||
auth_type=config.get('api_auth_type', 'none'),
|
||
auth_config=config.get('api_auth_config', {}),
|
||
retry_count=config.get('api_retry_count', 0),
|
||
retry_interval=config.get('api_retry_interval', 1),
|
||
success_condition=config.get('api_success_condition', {}),
|
||
proxy=config.get('api_proxy', ''),
|
||
follow_redirects=config.get('api_follow_redirects', True),
|
||
verify_ssl=config.get('api_verify_ssl', True),
|
||
params=params
|
||
)
|
||
|
||
@classmethod
|
||
async def _execute_api_internal(
|
||
cls,
|
||
url: str,
|
||
method: str,
|
||
headers: Dict[str, str],
|
||
body: Dict[str, Any],
|
||
timeout: int,
|
||
data_path: str,
|
||
params: Dict[str, Any],
|
||
query_params: List = None,
|
||
body_type: str = 'none',
|
||
content_type: str = '',
|
||
auth_type: str = 'none',
|
||
auth_config: Dict[str, Any] = None,
|
||
retry_count: int = 0,
|
||
retry_interval: int = 1,
|
||
success_condition: Dict[str, Any] = None,
|
||
proxy: str = '',
|
||
follow_redirects: bool = True,
|
||
verify_ssl: bool = True,
|
||
) -> Any:
|
||
"""内部 API 执行方法"""
|
||
import asyncio
|
||
if not url:
|
||
return []
|
||
|
||
query_params = query_params or []
|
||
auth_config = auth_config or {}
|
||
success_condition = success_condition or {}
|
||
|
||
# 替换 URL 中的参数占位符 {param}
|
||
for key, value in params.items():
|
||
url = url.replace(f'{{{key}}}', str(value) if value is not None else '')
|
||
|
||
# 处理请求头中的参数
|
||
final_headers = {}
|
||
for k, v in headers.items():
|
||
if isinstance(v, str):
|
||
for pk, pv in params.items():
|
||
v = v.replace(f'{{{pk}}}', str(pv) if pv is not None else '')
|
||
final_headers[k] = v
|
||
|
||
# 处理认证
|
||
cls._apply_auth(final_headers, auth_type, auth_config, params)
|
||
|
||
# 处理 Query 参数(合并可视化配置的和URL传入的)
|
||
final_query_params = dict(params) # 从参数定义中的默认值开始
|
||
logger.debug(f"[DataSource] Initial params: {params}")
|
||
for qp in query_params:
|
||
if qp.get('enabled', True) and qp.get('key'):
|
||
val = qp.get('value', '')
|
||
original_val = val
|
||
if isinstance(val, str):
|
||
for pk, pv in params.items():
|
||
val = val.replace(f'{{{pk}}}', str(pv) if pv is not None else '')
|
||
logger.debug(f"[DataSource] Query param {qp['key']}: {original_val} -> {val}")
|
||
final_query_params[qp['key']] = val
|
||
logger.debug(f"[DataSource] Final query params: {final_query_params}")
|
||
|
||
# 构建 httpx 客户端参数
|
||
client_kwargs = {
|
||
'timeout': timeout,
|
||
'follow_redirects': follow_redirects,
|
||
'verify': verify_ssl,
|
||
}
|
||
if proxy:
|
||
client_kwargs['proxy'] = proxy
|
||
|
||
last_error = None
|
||
max_attempts = max(1, retry_count + 1)
|
||
|
||
for attempt in range(max_attempts):
|
||
try:
|
||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||
method_upper = method.upper()
|
||
|
||
if method_upper == 'GET':
|
||
resp = await client.get(url, params=final_query_params, headers=final_headers)
|
||
else:
|
||
# 根据 body_type 构建请求
|
||
req_kwargs = {'headers': final_headers, 'params': final_query_params}
|
||
|
||
if body_type == 'json' or (body_type == 'none' and body):
|
||
final_body = cls._replace_params_in_dict(body.copy() if body else {}, params)
|
||
req_kwargs['json'] = final_body
|
||
elif body_type == 'form-data':
|
||
final_body = cls._replace_params_in_dict(body.copy() if body else {}, params)
|
||
req_kwargs['data'] = final_body
|
||
elif body_type == 'x-www-form-urlencoded':
|
||
final_body = cls._replace_params_in_dict(body.copy() if body else {}, params)
|
||
req_kwargs['data'] = final_body
|
||
final_headers.setdefault('Content-Type', 'application/x-www-form-urlencoded')
|
||
elif body_type == 'raw':
|
||
import json
|
||
raw_body = json.dumps(body) if isinstance(body, dict) else str(body)
|
||
for pk, pv in params.items():
|
||
raw_body = raw_body.replace(f'{{{pk}}}', str(pv) if pv is not None else '')
|
||
req_kwargs['content'] = raw_body.encode('utf-8')
|
||
if content_type:
|
||
final_headers['Content-Type'] = content_type
|
||
|
||
resp = await client.request(method_upper, url, **req_kwargs)
|
||
|
||
# 检查成功条件
|
||
expected_codes = success_condition.get('status_codes')
|
||
if expected_codes and isinstance(expected_codes, list):
|
||
if resp.status_code not in expected_codes:
|
||
raise ValueError(f"HTTP 状态码 {resp.status_code} 不在预期范围 {expected_codes} 内")
|
||
else:
|
||
resp.raise_for_status()
|
||
|
||
result = resp.json()
|
||
|
||
# 检查字段级成功条件
|
||
field_path = success_condition.get('field_path')
|
||
field_value = success_condition.get('field_value')
|
||
if field_path:
|
||
actual_value = cls._get_nested_value(result, field_path)
|
||
if field_value is not None and str(actual_value) != str(field_value):
|
||
raise ValueError(f"响应字段 {field_path} 的值 {actual_value} 不等于预期值 {field_value}")
|
||
|
||
# 提取数据路径
|
||
if data_path:
|
||
result = cls._get_nested_value(result, data_path)
|
||
|
||
return result if result is not None else []
|
||
|
||
except Exception as e:
|
||
last_error = e
|
||
if attempt < max_attempts - 1:
|
||
logger.warning(f"API 请求第 {attempt + 1} 次失败,{retry_interval}s 后重试: {str(e)}")
|
||
await asyncio.sleep(retry_interval)
|
||
continue
|
||
break
|
||
|
||
logger.error(f"API 请求失败(共 {max_attempts} 次尝试): {str(last_error)}")
|
||
raise ValueError(f"API 请求失败: {str(last_error)}")
|
||
|
||
@classmethod
|
||
def _apply_auth(
|
||
cls,
|
||
headers: Dict[str, str],
|
||
auth_type: str,
|
||
auth_config: Dict[str, Any],
|
||
params: Dict[str, Any]
|
||
):
|
||
"""应用认证配置到请求头"""
|
||
if auth_type == 'none' or not auth_config:
|
||
return
|
||
|
||
def _resolve(val: str) -> str:
|
||
if isinstance(val, str):
|
||
for pk, pv in params.items():
|
||
val = val.replace(f'{{{pk}}}', str(pv) if pv is not None else '')
|
||
return val
|
||
|
||
if auth_type == 'bearer_token':
|
||
token = _resolve(auth_config.get('token', ''))
|
||
if token:
|
||
headers['Authorization'] = f'Bearer {token}'
|
||
|
||
elif auth_type == 'basic_auth':
|
||
import base64
|
||
username = _resolve(auth_config.get('username', ''))
|
||
password = _resolve(auth_config.get('password', ''))
|
||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||
headers['Authorization'] = f'Basic {credentials}'
|
||
|
||
elif auth_type == 'api_key':
|
||
key_name = auth_config.get('key_name', '')
|
||
key_value = _resolve(auth_config.get('key_value', ''))
|
||
key_position = auth_config.get('key_position', 'header') # header / query
|
||
if key_name and key_value:
|
||
if key_position == 'header':
|
||
headers[key_name] = key_value
|
||
# query 位置的 key 在 query_params 中处理
|
||
|
||
# ==================== 结果转换 ====================
|
||
|
||
@classmethod
|
||
def _transform_result(cls, data: Any, source: DataSource) -> Any:
|
||
"""转换结果格式"""
|
||
return cls._transform_result_internal(
|
||
data=data,
|
||
result_type=source.result_type or 'list',
|
||
tree_config=source.tree_config or {},
|
||
chart_config=source.chart_config or {}
|
||
)
|
||
|
||
@classmethod
|
||
def _transform_result_temp(cls, data: Any, config: Dict[str, Any]) -> Any:
|
||
"""转换临时结果格式"""
|
||
return cls._transform_result_internal(
|
||
data=data,
|
||
result_type=config.get('result_type', 'list'),
|
||
tree_config=config.get('tree_config', {}),
|
||
chart_config=config.get('chart_config', {})
|
||
)
|
||
|
||
@classmethod
|
||
def _transform_result_internal(
|
||
cls,
|
||
data: Any,
|
||
result_type: str,
|
||
tree_config: Dict[str, Any],
|
||
chart_config: Dict[str, Any] = None
|
||
) -> Any:
|
||
"""内部结果转换方法"""
|
||
if not isinstance(data, list):
|
||
return data
|
||
|
||
if result_type == 'tree':
|
||
return cls._list_to_tree(
|
||
data,
|
||
id_field=tree_config.get('id_field', 'id'),
|
||
parent_field=tree_config.get('parent_field', 'parent_id'),
|
||
children_field=tree_config.get('children_field', 'children'),
|
||
root_value=tree_config.get('root_value', None),
|
||
)
|
||
elif result_type == 'object':
|
||
return data[0] if data else None
|
||
elif result_type == 'value':
|
||
if data and len(data) > 0:
|
||
first_row = data[0]
|
||
if isinstance(first_row, dict) and len(first_row) > 0:
|
||
return list(first_row.values())[0]
|
||
return None
|
||
elif result_type == 'chart-axis':
|
||
return cls._transform_to_chart_axis(data, chart_config or {})
|
||
elif result_type == 'chart-pie':
|
||
return cls._transform_to_chart_pie(data, chart_config or {})
|
||
elif result_type == 'chart-gauge':
|
||
return cls._transform_to_chart_gauge(data, chart_config or {})
|
||
elif result_type == 'chart-radar':
|
||
return cls._transform_to_chart_radar(data, chart_config or {})
|
||
elif result_type == 'chart-scatter':
|
||
return cls._transform_to_chart_scatter(data, chart_config or {})
|
||
elif result_type == 'chart-heatmap':
|
||
return cls._transform_to_chart_heatmap(data, chart_config or {})
|
||
|
||
return data
|
||
|
||
@classmethod
|
||
def _transform_to_chart_axis(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为轴向图表数据格式"""
|
||
if not data:
|
||
return {"xAxisData": [], "seriesData": []}
|
||
|
||
x_field = config.get('x_field', '')
|
||
series_fields = config.get('series_fields', [])
|
||
series_names = config.get('series_names', [])
|
||
|
||
if not x_field or not series_fields:
|
||
if data and isinstance(data[0], dict):
|
||
keys = list(data[0].keys())
|
||
if not x_field and keys:
|
||
x_field = keys[0]
|
||
if not series_fields and len(keys) > 1:
|
||
series_fields = keys[1:]
|
||
|
||
x_axis_data = [item.get(x_field, '') for item in data]
|
||
|
||
series_data = []
|
||
for i, field in enumerate(series_fields):
|
||
name = series_names[i] if i < len(series_names) else field
|
||
values = [item.get(field, 0) for item in data]
|
||
series_data.append({"name": name, "data": values})
|
||
|
||
return {"xAxisData": x_axis_data, "seriesData": series_data}
|
||
|
||
@classmethod
|
||
def _transform_to_chart_pie(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为饼图数据格式"""
|
||
if not data:
|
||
return {"seriesData": []}
|
||
|
||
name_field = config.get('name_field', '')
|
||
value_field = config.get('value_field', '')
|
||
|
||
if not name_field or not value_field:
|
||
if data and isinstance(data[0], dict):
|
||
keys = list(data[0].keys())
|
||
if len(keys) >= 2:
|
||
if not name_field:
|
||
name_field = keys[0]
|
||
if not value_field:
|
||
value_field = keys[1]
|
||
|
||
series_data = []
|
||
for item in data:
|
||
series_data.append({
|
||
"name": item.get(name_field, ''),
|
||
"value": item.get(value_field, 0)
|
||
})
|
||
|
||
return {"seriesData": series_data}
|
||
|
||
@classmethod
|
||
def _transform_to_chart_gauge(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为仪表盘数据格式"""
|
||
if not data:
|
||
return {"value": 0, "name": "", "max": 100}
|
||
|
||
value_field = config.get('value_field', 'value')
|
||
name_field = config.get('name_field', 'name')
|
||
max_field = config.get('max_field', 'max')
|
||
|
||
first_row = data[0] if data else {}
|
||
|
||
return {
|
||
"value": first_row.get(value_field, 0),
|
||
"name": first_row.get(name_field, ''),
|
||
"max": first_row.get(max_field, 100)
|
||
}
|
||
|
||
@classmethod
|
||
def _transform_to_chart_radar(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为雷达图数据格式"""
|
||
if not data:
|
||
return {"indicator": [], "seriesData": []}
|
||
|
||
indicator_field = config.get('indicator_field', 'name')
|
||
max_field = config.get('max_field', 'max')
|
||
value_fields = config.get('value_fields', [])
|
||
series_names = config.get('series_names', [])
|
||
|
||
if not value_fields and data:
|
||
keys = list(data[0].keys())
|
||
value_fields = [k for k in keys if k not in [indicator_field, max_field]]
|
||
|
||
indicator = []
|
||
for item in data:
|
||
indicator.append({
|
||
"name": item.get(indicator_field, ''),
|
||
"max": item.get(max_field, 100)
|
||
})
|
||
|
||
series_data = []
|
||
for i, field in enumerate(value_fields):
|
||
name = series_names[i] if i < len(series_names) else field
|
||
values = [item.get(field, 0) for item in data]
|
||
series_data.append({"name": name, "value": values})
|
||
|
||
return {"indicator": indicator, "seriesData": series_data}
|
||
|
||
@classmethod
|
||
def _transform_to_chart_scatter(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为散点图数据格式"""
|
||
if not data:
|
||
return {"seriesData": []}
|
||
|
||
x_field = config.get('x_field', 'x')
|
||
y_field = config.get('y_field', 'y')
|
||
size_field = config.get('size_field', '')
|
||
name_field = config.get('name_field', '')
|
||
|
||
series_data = []
|
||
for item in data:
|
||
point = [item.get(x_field, 0), item.get(y_field, 0)]
|
||
if size_field:
|
||
point.append(item.get(size_field, 0))
|
||
if name_field:
|
||
point.append(item.get(name_field, ''))
|
||
series_data.append(point)
|
||
|
||
return {"seriesData": series_data}
|
||
|
||
@classmethod
|
||
def _transform_to_chart_heatmap(cls, data: List[Dict], config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""转换为热力图数据格式"""
|
||
if not data:
|
||
return {"xAxisData": [], "yAxisData": [], "seriesData": []}
|
||
|
||
x_field = config.get('x_field', 'x')
|
||
y_field = config.get('y_field', 'y')
|
||
value_field = config.get('value_field', 'value')
|
||
|
||
x_values = list(dict.fromkeys(item.get(x_field, '') for item in data))
|
||
y_values = list(dict.fromkeys(item.get(y_field, '') for item in data))
|
||
|
||
x_index = {v: i for i, v in enumerate(x_values)}
|
||
y_index = {v: i for i, v in enumerate(y_values)}
|
||
|
||
series_data = []
|
||
for item in data:
|
||
x = item.get(x_field, '')
|
||
y = item.get(y_field, '')
|
||
value = item.get(value_field, 0)
|
||
series_data.append([x_index.get(x, 0), y_index.get(y, 0), value])
|
||
|
||
return {"xAxisData": x_values, "yAxisData": y_values, "seriesData": series_data}
|
||
|
||
@classmethod
|
||
def _list_to_tree(
|
||
cls,
|
||
data: List[Dict],
|
||
id_field: str,
|
||
parent_field: str,
|
||
children_field: str,
|
||
root_value: Any = None
|
||
) -> List[Dict]:
|
||
"""列表转树形结构"""
|
||
if not data:
|
||
return []
|
||
|
||
mapping = {}
|
||
for item in data:
|
||
item_id = item.get(id_field)
|
||
if item_id is not None:
|
||
mapping[item_id] = {**item, children_field: []}
|
||
|
||
tree = []
|
||
for item in data:
|
||
item_id = item.get(id_field)
|
||
parent_id = item.get(parent_field)
|
||
node = mapping.get(item_id)
|
||
|
||
if node is None:
|
||
continue
|
||
|
||
is_root = (
|
||
parent_id is None or
|
||
parent_id == root_value or
|
||
parent_id == '' or
|
||
parent_id not in mapping
|
||
)
|
||
|
||
if is_root:
|
||
tree.append(node)
|
||
else:
|
||
parent_node = mapping.get(parent_id)
|
||
if parent_node:
|
||
parent_node[children_field].append(node)
|
||
|
||
return tree
|
||
|
||
# ==================== 工具方法 ====================
|
||
|
||
@classmethod
|
||
def _apply_field_mapping(cls, data: List[Dict], mapping: Dict[str, str]) -> List[Dict]:
|
||
"""应用字段映射"""
|
||
if not data or not mapping:
|
||
return data
|
||
|
||
result = []
|
||
for item in data:
|
||
if not isinstance(item, dict):
|
||
result.append(item)
|
||
continue
|
||
|
||
new_item = {}
|
||
for old_key, new_key in mapping.items():
|
||
if old_key in item:
|
||
new_item[new_key] = item[old_key]
|
||
for key, value in item.items():
|
||
if key not in mapping:
|
||
new_item[key] = value
|
||
result.append(new_item)
|
||
|
||
return result
|
||
|
||
@classmethod
|
||
def _merge_params(cls, param_defs: List[Dict], input_params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""合并参数"""
|
||
result = {}
|
||
|
||
for p in param_defs:
|
||
name = p.get('name')
|
||
if not name:
|
||
continue
|
||
|
||
param_type = p.get('type', 'string')
|
||
required = p.get('required', False)
|
||
default = p.get('default')
|
||
|
||
if name in input_params:
|
||
value = input_params[name]
|
||
result[name] = cls._convert_param_type(value, param_type)
|
||
elif default is not None:
|
||
result[name] = cls._convert_param_type(default, param_type)
|
||
elif required:
|
||
raise ValueError(f"缺少必填参数: {name}")
|
||
else:
|
||
result[name] = None
|
||
|
||
for key, value in input_params.items():
|
||
if key not in result:
|
||
result[key] = cls._auto_convert_param(value)
|
||
|
||
return result
|
||
|
||
@classmethod
|
||
def _auto_convert_param(cls, value: Any) -> Any:
|
||
"""对未在参数定义中声明的参数,自动推断并转换常见类型(date/datetime)"""
|
||
if value is None or not isinstance(value, str):
|
||
return value
|
||
s = value.strip()
|
||
if not s:
|
||
return value
|
||
import re
|
||
from datetime import date, datetime as dt
|
||
if re.fullmatch(r'\d{4}-\d{2}-\d{2}', s):
|
||
try:
|
||
return date.fromisoformat(s)
|
||
except ValueError:
|
||
pass
|
||
if re.fullmatch(r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?', s):
|
||
try:
|
||
return dt.fromisoformat(s)
|
||
except ValueError:
|
||
pass
|
||
return value
|
||
|
||
@classmethod
|
||
def _convert_param_type(cls, value: Any, param_type: str) -> Any:
|
||
"""参数类型转换"""
|
||
if value is None:
|
||
return None
|
||
|
||
try:
|
||
if param_type == 'integer':
|
||
return int(value)
|
||
elif param_type == 'float':
|
||
return float(value)
|
||
elif param_type == 'boolean':
|
||
if isinstance(value, bool):
|
||
return value
|
||
return str(value).lower() in ('true', '1', 'yes')
|
||
elif param_type == 'date':
|
||
from datetime import date, datetime as dt
|
||
if isinstance(value, date):
|
||
return value
|
||
if isinstance(value, dt):
|
||
return value.date()
|
||
s = str(value).strip()[:10]
|
||
return date.fromisoformat(s)
|
||
elif param_type == 'datetime':
|
||
from datetime import datetime as dt
|
||
if isinstance(value, dt):
|
||
return value
|
||
s = str(value).strip()
|
||
return dt.fromisoformat(s)
|
||
else:
|
||
return str(value)
|
||
except (ValueError, TypeError):
|
||
return value
|
||
|
||
@classmethod
|
||
def _replace_params_in_dict(cls, data: Dict, params: Dict[str, Any]) -> Dict:
|
||
"""递归替换字典中的参数占位符"""
|
||
result = {}
|
||
for key, value in data.items():
|
||
if isinstance(value, str):
|
||
for pk, pv in params.items():
|
||
value = value.replace(f'{{{pk}}}', str(pv) if pv is not None else '')
|
||
result[key] = value
|
||
elif isinstance(value, dict):
|
||
result[key] = cls._replace_params_in_dict(value, params)
|
||
elif isinstance(value, list):
|
||
result[key] = [
|
||
cls._replace_params_in_dict(item, params) if isinstance(item, dict) else item
|
||
for item in value
|
||
]
|
||
else:
|
||
result[key] = value
|
||
return result
|
||
|
||
@classmethod
|
||
def _get_nested_value(cls, data: Any, path: str) -> Any:
|
||
"""获取嵌套字典中的值"""
|
||
if not path:
|
||
return data
|
||
|
||
keys = path.split('.')
|
||
result = data
|
||
|
||
for key in keys:
|
||
if isinstance(result, dict):
|
||
result = result.get(key)
|
||
elif isinstance(result, list) and key.isdigit():
|
||
index = int(key)
|
||
result = result[index] if 0 <= index < len(result) else None
|
||
else:
|
||
return None
|
||
|
||
if result is None:
|
||
return None
|
||
|
||
return result
|
||
|
||
# ==================== 缓存方法 ====================
|
||
|
||
@classmethod
|
||
def _get_cache_key(cls, code: str, params: Dict[str, Any]) -> str:
|
||
"""生成缓存键"""
|
||
params_str = str(sorted(params.items()))
|
||
params_hash = hashlib.md5(params_str.encode()).hexdigest()[:8]
|
||
return f"datasource:{code}:{params_hash}"
|
||
|
||
@classmethod
|
||
async def _get_cache(cls, key: str) -> Any:
|
||
"""获取缓存"""
|
||
try:
|
||
import json
|
||
client = await RedisClient.get_client()
|
||
value = await client.get(key)
|
||
if value:
|
||
return json.loads(value)
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"获取缓存失败: {str(e)}")
|
||
return None
|
||
|
||
@classmethod
|
||
async def _set_cache(cls, key: str, value: Any, ttl: int) -> None:
|
||
"""设置缓存"""
|
||
try:
|
||
import json
|
||
client = await RedisClient.get_client()
|
||
await client.set(key, json.dumps(value, default=str), ex=ttl)
|
||
except Exception as e:
|
||
logger.warning(f"设置缓存失败: {str(e)}")
|
||
|
||
@classmethod
|
||
async def clear_cache(cls, code: str) -> None:
|
||
"""清除数据源缓存"""
|
||
pattern = f"datasource:{code}:*"
|
||
try:
|
||
client = await RedisClient.get_client()
|
||
keys = []
|
||
async for key in client.scan_iter(match=pattern):
|
||
keys.append(key)
|
||
if keys:
|
||
await client.delete(*keys)
|
||
logger.info(f"已清除数据源 {code} 的 {len(keys)} 个缓存")
|
||
except Exception as e:
|
||
logger.warning(f"清除缓存失败: {str(e)}")
|
||
|
||
# ==================== AI SQL 生成 ====================
|
||
|
||
@classmethod
|
||
async def ai_generate_sql(
|
||
cls,
|
||
db: AsyncSession,
|
||
user_question: str,
|
||
db_connection: str,
|
||
database: str = "",
|
||
schema_name: str = "",
|
||
selected_tables: List[str] = None,
|
||
table_fields: Dict[str, List[Dict[str, Any]]] = None,
|
||
table_relations: List[Dict[str, Any]] = None,
|
||
include_table_relations: bool = True,
|
||
model_id: str = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
使用 AI 生成 SQL 语句
|
||
复用 Text-to-SQL 节点的核心逻辑
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
user_question: 用户问题(自然语言描述)
|
||
db_connection: 数据库连接名称
|
||
database: 数据库名
|
||
schema_name: Schema 名称
|
||
selected_tables: 选中的表名列表
|
||
table_fields: 表字段信息(前端传递,可选)
|
||
table_relations: 表关系列表
|
||
include_table_relations: 是否包含表关系信息
|
||
model_id: LLM 模型 ID
|
||
|
||
Returns:
|
||
包含 sql, thought, params 的字典
|
||
"""
|
||
if not user_question:
|
||
raise ValueError("请输入查询需求")
|
||
|
||
if not model_id:
|
||
raise ValueError("请选择 AI 模型")
|
||
|
||
# 直接复用 TextToSqlNode 的逻辑
|
||
from ai_platform.nodes.builtin.text_to_sql_node import TextToSqlNode
|
||
from ai_platform.nodes.base import NodeContext
|
||
|
||
# 构建节点配置
|
||
node = TextToSqlNode()
|
||
node.config = {
|
||
'user_question': user_question,
|
||
'db_config': {
|
||
'dbName': db_connection,
|
||
'database': database,
|
||
'schema': schema_name or 'public',
|
||
},
|
||
'selected_tables': selected_tables or [],
|
||
'table_relations': table_relations or [],
|
||
'include_table_relations': include_table_relations,
|
||
'model_id': model_id,
|
||
}
|
||
|
||
# 创建临时的 NodeContext
|
||
context = NodeContext(
|
||
workflow_run_id='temp_ai_sql_generation',
|
||
node_config=node.config,
|
||
variables={},
|
||
db_session=db,
|
||
)
|
||
|
||
try:
|
||
# 调用 Text-to-SQL 节点的异步执行方法
|
||
result = await node.execute_async(context)
|
||
|
||
if not result.success:
|
||
raise ValueError(result.error or "生成失败")
|
||
|
||
sql = result.output.get('sql', '')
|
||
thought = result.output.get('thought', '')
|
||
|
||
# 提取参数定义
|
||
params = cls._extract_sql_params(sql)
|
||
|
||
return {
|
||
'sql': sql,
|
||
'thought': thought,
|
||
'params': params,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"AI 生成 SQL 失败: {str(e)}")
|
||
raise ValueError(f"生成失败: {str(e)}")
|
||
|
||
@classmethod
|
||
def _extract_sql_params(cls, sql: str) -> List[Dict[str, Any]]:
|
||
"""从 SQL 中提取参数占位符,生成参数定义"""
|
||
# 匹配 :param_name 格式的参数
|
||
pattern = r':(\w+)'
|
||
matches = re.findall(pattern, sql)
|
||
|
||
params = []
|
||
seen = set()
|
||
|
||
for param_name in matches:
|
||
if param_name in seen:
|
||
continue
|
||
seen.add(param_name)
|
||
|
||
# 根据参数名推测类型和默认值
|
||
param_type = 'string'
|
||
default_value = None
|
||
required = False
|
||
|
||
name_lower = param_name.lower()
|
||
|
||
if 'date' in name_lower or 'time' in name_lower:
|
||
param_type = 'string'
|
||
elif 'id' in name_lower:
|
||
param_type = 'string'
|
||
elif 'status' in name_lower or 'type' in name_lower:
|
||
param_type = 'integer'
|
||
elif 'limit' in name_lower:
|
||
param_type = 'integer'
|
||
default_value = 20
|
||
elif 'offset' in name_lower:
|
||
param_type = 'integer'
|
||
default_value = 0
|
||
elif 'page' in name_lower:
|
||
param_type = 'integer'
|
||
default_value = 1
|
||
elif 'count' in name_lower or 'num' in name_lower:
|
||
param_type = 'integer'
|
||
|
||
# 生成显示名称
|
||
label = param_name.replace('_', ' ').title()
|
||
|
||
params.append({
|
||
'name': param_name,
|
||
'label': label,
|
||
'type': param_type,
|
||
'required': required,
|
||
'default': default_value,
|
||
})
|
||
|
||
return params
|