#!/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