Files
ai-agent-admin/backend-fastapi/online_dev/form_data_manager/service.py
T
2026-06-09 21:18:33 +08:00

5520 lines
229 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表单数据操作服务(异步版本)
支持动态操作不同数据表,适配 PostgreSQL、MySQL
"""
import asyncio
import json
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import date, datetime
from decimal import Decimal
from io import BytesIO
from typing import Any, Dict, List, Optional, Set, Tuple
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.form_manager.model import FormMeta, FormSubTable
from online_dev.form_data_manager.db_adapter import (
PLATFORM_SCHEMA,
FormDataDbAdapter,
create_form_data_adapter,
create_platform_adapter,
create_platform_sql_builder,
resolve_form_sql_context,
)
from online_dev.form_data_manager.dynamic_sql_builder import DynamicSQLBuilder
from online_dev.form_data_manager.exceptions import (
FormDataException,
FormNotFoundException,
RecordNotFoundException,
UniqueConstraintError,
ColumnNotFoundError,
FormDataValidationError,
ForeignKeyConstraintError,
)
from online_dev.form_data_manager.db_error_handler import (
translate_db_error,
format_error_message,
is_aborted_transaction_error,
)
from core.database_manager.service import AsyncDatabaseManagerService
from app.timezone import APP_TIMEZONE
logger = logging.getLogger(__name__)
def get_max_import_rows() -> int:
"""根据服务器物理内存计算最大导入/导出行数"""
import psutil
total_gb = psutil.virtual_memory().total / (1024 ** 3)
thresholds = [(2, 100_000), (4, 200_000), (8, 600_000), (16, 1_048_575)]
if total_gb <= 2:
return 100_000
for i in range(len(thresholds) - 1):
low_gb, low_rows = thresholds[i]
high_gb, high_rows = thresholds[i + 1]
if total_gb <= high_gb:
ratio = (total_gb - low_gb) / (high_gb - low_gb)
return int(low_rows + ratio * (high_rows - low_rows))
return 1_048_575
def get_server_memory_gb() -> float:
"""获取服务器物理内存(GB"""
import psutil
return round(psutil.virtual_memory().total / (1024 ** 3), 1)
MAX_IMPORT_EXPORT_ROWS = get_max_import_rows()
SERVER_MEMORY_GB = get_server_memory_gb()
logger.info(f"服务器内存: {SERVER_MEMORY_GB}GB, 导入导出行数上限: {MAX_IMPORT_EXPORT_ROWS}")
class FormDataService:
"""
表单数据操作服务(异步版本)
根据表单配置动态操作数据表,支持主表和子表的 CRUD 操作
"""
def __init__(
self,
form_meta: FormMeta,
sub_tables: List[FormSubTable],
db_adapter: FormDataDbAdapter,
):
"""
初始化服务
Args:
form_meta: 表单元数据
sub_tables: 子表配置列表
db_adapter: 数据库执行适配器(平台库或第三方连接)
"""
self.form_meta = form_meta
self.sub_tables = sub_tables
self.db_adapter = db_adapter
self.db_type = db_adapter.db_type
self.db_config = db_adapter.db_config
from online_dev.form_data_manager.db_adapter import create_sql_builder_for_adapter
self.sql_builder = create_sql_builder_for_adapter(db_adapter)
self._platform_adapter: Optional[FormDataDbAdapter] = None
self._platform_sql_builder = create_platform_sql_builder()
self._adapter_cache: Dict[str, FormDataDbAdapter] = {}
self._sql_builder_cache: Dict[str, DynamicSQLBuilder] = {}
# 缓存标量属性,避免 session 过期后懒加载导致 MissingGreenlet
self._form_code = form_meta.code
self._form_config = form_meta.form_config or {}
self._list_config = form_meta.list_config or {}
async def _resolve_schema(self, db: AsyncSession, schema: str) -> Optional[str]:
"""
解析 schema 中的变量
如果 schema 包含 {{application_code}} 变量,则从表单关联的应用中获取 application_code
Args:
db: 数据库会话
schema: 原始 schema 配置(可能包含变量)
Returns:
解析后的 schema 值
"""
if not schema:
return None
# 检查是否包含变量语法
if '{{' in schema and '}}' in schema:
# 提取变量名
import re
match = re.search(r'\{\{([^}]+)\}\}', schema)
if match:
var_name = match.group(1).strip()
# 如果是 application_code 变量,从应用中获取
if 'application_code' in var_name and self.form_meta.application_id:
from core.application.model import Application
result = await db.execute(
select(Application.code).where(Application.id == self.form_meta.application_id)
)
app_code = result.scalar_one_or_none()
if app_code:
# 替换变量
return schema.replace(f'{{{{{var_name}}}}}', app_code)
return schema
@classmethod
async def create_service(cls, db: AsyncSession, form_code: str) -> "FormDataService":
"""
工厂方法:创建服务实例
Args:
db: 数据库会话
form_code: 表单编码
"""
# 加载表单元数据
stmt = select(FormMeta).where(
FormMeta.code == form_code,
FormMeta.is_deleted == False
)
result = await db.execute(stmt)
form_meta = result.scalar_one_or_none()
if not form_meta:
raise FormNotFoundException(form_code)
# 加载子表配置
sub_stmt = select(FormSubTable).where(
FormSubTable.form_id == form_meta.id,
FormSubTable.is_deleted == False
).order_by(FormSubTable.sort)
sub_result = await db.execute(sub_stmt)
sub_tables = list(sub_result.scalars().all())
db_config = (form_meta.db_config or "default").strip() or "default"
db_adapter = await create_form_data_adapter(db_config, db)
service = cls(form_meta, sub_tables, db_adapter)
service._platform_adapter = await create_platform_adapter(db)
service._adapter_cache[db_config] = db_adapter
service._sql_builder_cache[db_config] = service.sql_builder
return service
async def _get_platform_adapter(self, db: AsyncSession) -> FormDataDbAdapter:
if self._platform_adapter is None:
self._platform_adapter = await create_platform_adapter(db)
return self._platform_adapter
async def _execute_platform_query(
self, db: AsyncSession, sql: str, params: Any = None
) -> List[Dict[str, Any]]:
adapter = await self._get_platform_adapter(db)
return await adapter.execute_query(sql, params)
async def _execute_platform_command(
self, db: AsyncSession, sql: str, params: Any = None
) -> int:
adapter = await self._get_platform_adapter(db)
return await adapter.execute_command(sql, params)
def _resolve_exec_database(self, database: Optional[str] = None) -> Optional[str]:
"""第三方 PostgreSQL 执行/事务时切换连接库(连接未配置 default 时用表单/子表 database)。"""
if not self.db_adapter.is_external or self.db_adapter.db_type != "postgresql":
return None
explicit = (database or "").strip()
if explicit:
return explicit
form_db = (self.form_meta.main_table_database or "").strip()
if form_db:
return form_db
conn_default = (getattr(self.db_adapter, "default_database", "") or "").strip()
return conn_default or None
def _table_exec_database(self, table_database: Optional[str] = None) -> Optional[str]:
"""主表/子表操作对应的 PG 连接库名(子表 table_database 优先,否则主表)。"""
effective = (table_database or "").strip() or (
self.form_meta.main_table_database or ""
).strip()
return self._resolve_exec_database(effective or None)
@asynccontextmanager
async def _business_transaction(self, database: Optional[str] = None):
"""业务库事务:第三方走 handler 事务域,default 与平台 Session 一致。"""
if self.db_adapter.is_external:
exec_db = self._resolve_exec_database(database)
async with self.db_adapter.transaction(database=exec_db):
yield
else:
yield
async def _create_savepoint(self, db: AsyncSession, name: str) -> None:
if self.db_adapter.is_external:
await self.db_adapter.create_savepoint(name)
else:
await db.execute(text(f"SAVEPOINT {name}"))
async def _release_savepoint(self, db: AsyncSession, name: str) -> None:
if self.db_adapter.is_external:
await self.db_adapter.release_savepoint(name)
else:
await db.execute(text(f"RELEASE SAVEPOINT {name}"))
async def _rollback_to_savepoint(self, db: AsyncSession, name: str) -> None:
if self.db_adapter.is_external:
await self.db_adapter.rollback_to_savepoint(name)
else:
await db.execute(text(f"ROLLBACK TO SAVEPOINT {name}"))
# ============ 字段白名单 ============
def _get_allowed_fields(self, table_type: str = "main", table_name: str = None) -> Set[str]:
"""
从表单配置中提取允许的字段(白名单)
"""
fields = set()
form_config = self._form_config
items = form_config.get("items", [])
def traverse(item_list: List[Dict], in_sub_table: str = None):
"""递归遍历表单项"""
for item in item_list:
item_type = item.get("type", "")
field = item.get("field", "")
# 子表单组件
if item_type == "sub-table":
sub_table_name = field
children = item.get("children", [])
traverse(children, sub_table_name)
continue
# 布局/展示组件,递归处理或跳过
if item_type in ("grid", "tabs", "collapse", "steps", "divider", "alert", "timeline", "text", "html", "spacer", "title", "button"):
if item.get("columns"):
for col in item["columns"]:
traverse(col.get("children", []), in_sub_table)
if item.get("items"):
for sub_item in item["items"]:
traverse(sub_item.get("children", []), in_sub_table)
continue
# 普通字段(排除虚拟字段,虚拟字段不对应数据库列)
if field:
props = item.get("props", {})
if props.get("isVirtualField"):
continue
if table_type == "main" and in_sub_table is None:
fields.add(field)
elif table_type == "sub" and in_sub_table == table_name:
fields.add(field)
traverse(items)
# 始终允许 id 字段
fields.add("id")
return fields
def _filter_fields(self, data: Dict[str, Any], allowed_fields: Set[str]) -> Dict[str, Any]:
"""过滤字段,只保留白名单中的字段"""
return {k: v for k, v in data.items() if k in allowed_fields}
def _fill_system_fields_for_create(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
填充新增时的系统字段
sys_create_datetime, sys_update_datetime, sys_creator_id, sys_modifier_id, sys_dept_id, sort
"""
from utils.context import get_current_user_info_from_context
now = datetime.now()
user_info = get_current_user_info_from_context()
user_id = user_info.get('user_id') if user_info else None
dept_id = user_info.get('dept_id') if user_info else None
# 设置创建时间和更新时间
data['sys_create_datetime'] = now
data['sys_update_datetime'] = now
# 设置创建人和修改人
if user_id:
data['sys_creator_id'] = user_id
data['sys_modifier_id'] = user_id
# 设置部门
if dept_id:
data['sys_dept_id'] = dept_id
# 设置排序字段默认值(如果表中有 sort 字段且未提供值)
if 'sort' not in data or data.get('sort') is None:
data['sort'] = 0
return data
def _fill_system_fields_for_update(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
填充更新时的系统字段
sys_update_datetime, sys_modifier_id
"""
from utils.context import get_current_user_info_from_context
now = datetime.now()
user_info = get_current_user_info_from_context()
user_id = user_info.get('user_id') if user_info else None
# 设置更新时间
data['sys_update_datetime'] = now
# 设置修改人
if user_id:
data['sys_modifier_id'] = user_id
# 移除不应该被更新的系统字段
data.pop('sys_create_datetime', None)
data.pop('sys_creator_id', None)
data.pop('sys_dept_id', None)
return data
def _apply_data_scope_filter(
self,
filters: Dict[str, Any],
data_scope: Dict[str, Any]
) -> Dict[str, Any]:
"""
应用数据权限过滤条件
Args:
filters: 现有过滤条件
data_scope: 数据权限配置,格式:
{
'filter_type': 'all' | 'self' | 'dept' | 'dept_and_children' | 'custom',
'scope': 0-4,
'user_id': str | None,
'dept_id': str | None,
'dept_ids': List[str] | None
}
Returns:
合并后的过滤条件
"""
if not filters:
filters = {}
filter_type = data_scope.get('filter_type', 'all')
if filter_type == 'all':
# 全部数据,不添加过滤条件
return filters
elif filter_type == 'self':
# 仅本人数据:需要表中有 sys_creator_id 字段
user_id = data_scope.get('user_id')
if user_id:
filters['sys_creator_id'] = user_id
elif filter_type == 'dept':
# 本部门数据:需要表中有 dept_id 字段
dept_id = data_scope.get('dept_id')
if dept_id:
filters['dept_id'] = dept_id
elif filter_type == 'dept_and_children':
# 本部门及下级:需要表中有 dept_id 字段
dept_ids = data_scope.get('dept_ids', [])
if dept_ids:
filters['dept_id'] = {"type": "in", "value": dept_ids}
elif filter_type == 'custom':
# 自定义部门
dept_ids = data_scope.get('dept_ids', [])
if dept_ids:
filters['dept_id'] = {"type": "in", "value": dept_ids}
return filters
async def _apply_data_scope_filter_safe(
self,
db: AsyncSession,
table: str,
schema: Optional[str],
database: Optional[str],
filters: Dict[str, Any],
data_scope: Dict[str, Any]
) -> Dict[str, Any]:
"""
安全地应用数据权限过滤(检查字段是否存在)
如果表中不存在所需的字段,则跳过该过滤条件
"""
if not filters:
filters = {}
filter_type = data_scope.get('filter_type', 'all')
if filter_type == 'all':
# 全部数据,不添加过滤条件
return filters
# 获取表的所有列
columns = await self._get_table_columns(db, table, schema, database)
if filter_type == 'self':
# 仅本人数据:需要表中有 sys_creator_id 字段
# 注意:字段为空时所有人可见(使用 eq_or_null)
if 'sys_creator_id' in columns:
user_id = data_scope.get('user_id')
if user_id:
filters['sys_creator_id'] = {"type": "eq_or_null", "value": user_id}
else:
logger.warning(f"{table} 中不存在 sys_creator_id 字段,跳过数据权限过滤")
elif filter_type == 'dept':
# 本部门数据:需要表中有 dept_id 或 sys_dept_id 字段
# 注意:字段为空时所有人可见(使用 eq_or_null)
dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None)
if dept_field:
dept_id = data_scope.get('dept_id')
if dept_id:
filters[dept_field] = {"type": "eq_or_null", "value": dept_id}
else:
logger.warning(f"{table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤")
elif filter_type == 'dept_and_children':
# 本部门及下级:需要表中有 dept_id 或 sys_dept_id 字段
# 注意:字段为空时所有人可见(使用 in_or_null)
dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None)
if dept_field:
dept_ids = data_scope.get('dept_ids', [])
if dept_ids:
filters[dept_field] = {"type": "in_or_null", "value": dept_ids}
else:
# 如果没有部门ID,只返回字段为空的记录
filters[dept_field] = {"type": "null"}
else:
logger.warning(f"{table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤")
elif filter_type == 'custom':
# 自定义部门
# 注意:字段为空时所有人可见(使用 in_or_null)
dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None)
if dept_field:
dept_ids = data_scope.get('dept_ids', [])
if dept_ids:
filters[dept_field] = {"type": "in_or_null", "value": dept_ids}
else:
# 如果没有部门ID,只返回字段为空的记录
filters[dept_field] = {"type": "null"}
else:
logger.warning(f"{table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤")
return filters
# ============ 数据库操作 ============
async def _try_enrich_foreign_key_error(
self,
db: AsyncSession,
exc: ForeignKeyConstraintError,
) -> ForeignKeyConstraintError:
"""在传入会话上解析引用表名;事务已中止时 rollback 后重试。"""
referenced_table = exc.context.get("referenced_table")
if not referenced_table:
return exc
display_name: Optional[str] = None
try:
display_name = await self._resolve_table_display_name(db, referenced_table)
except Exception as first_err:
if not is_aborted_transaction_error(first_err):
logger.warning(
"Failed to enrich foreign key error for table %s: %s",
referenced_table,
first_err,
)
return exc
try:
await db.rollback()
display_name = await self._resolve_table_display_name(
db, referenced_table
)
except Exception as enrich_err:
logger.warning(
"Failed to enrich foreign key error for table %s: %s",
referenced_table,
enrich_err,
)
return exc
if not display_name or display_name == referenced_table:
return exc
message = str(exc)
if "无法删除" in message:
message = (
f"无法删除,该数据已被「{display_name}」引用,"
"请先删除或解除关联数据"
)
elif referenced_table in message:
message = message.replace(referenced_table, display_name)
else:
message = f"操作失败,引用的「{display_name}」数据不存在或无效"
return ForeignKeyConstraintError(
message=message,
detail=exc.context.get("detail", ""),
referenced_table=referenced_table,
constraint_name=exc.context.get("constraint_name", ""),
)
async def _resolve_table_display_name(
self,
db: AsyncSession,
table_name: str,
) -> str:
stmt = select(FormMeta.name).where(
FormMeta.main_table == table_name,
FormMeta.is_deleted == False,
).limit(1)
result = await db.execute(stmt)
form_name = result.scalar_one_or_none()
if form_name:
return form_name
sub_stmt = (
select(FormMeta.name)
.join(FormSubTable, FormSubTable.form_id == FormMeta.id)
.where(
FormSubTable.table_name == table_name,
FormMeta.is_deleted == False,
)
.limit(1)
)
sub_result = await db.execute(sub_stmt)
return sub_result.scalar_one_or_none() or table_name
async def _execute_query(
self,
db: AsyncSession,
sql: str,
params: Any = None,
*,
database: Optional[str] = None,
enrich_fk: bool = True,
) -> List[Dict[str, Any]]:
"""执行查询,自动将数据库异常转换为业务异常"""
if params is None:
params = {}
try:
exec_db = self._resolve_exec_database(database)
return await self.db_adapter.execute_query(
sql, params, database=exec_db
)
except FormDataException:
raise
except Exception as e:
biz_exc = translate_db_error(e)
if enrich_fk and isinstance(biz_exc, ForeignKeyConstraintError):
biz_exc = await self._try_enrich_foreign_key_error(db, biz_exc)
raise biz_exc from e
async def _execute_command(
self,
db: AsyncSession,
sql: str,
params: Any = None,
*,
database: Optional[str] = None,
enrich_fk: bool = True,
) -> int:
"""执行命令,返回影响行数,自动将数据库异常转换为业务异常"""
if params is None:
params = {}
try:
self.db_adapter.ensure_write_allowed()
exec_db = self._resolve_exec_database(database)
return await self.db_adapter.execute_command(
sql, params, database=exec_db
)
except FormDataException:
raise
except Exception as e:
biz_exc = translate_db_error(e)
if enrich_fk and isinstance(biz_exc, ForeignKeyConstraintError):
biz_exc = await self._try_enrich_foreign_key_error(db, biz_exc)
raise biz_exc from e
async def _get_table_columns(
self,
db: AsyncSession,
table: str,
schema: Optional[str],
database: Optional[str],
*,
sql_builder: Optional[DynamicSQLBuilder] = None,
db_adapter: Optional[FormDataDbAdapter] = None,
) -> List[str]:
"""获取表的所有列名"""
sb = sql_builder or self.sql_builder
adapter = db_adapter or self.db_adapter
try:
if sb.db_type == "postgresql":
schema_name = schema or "public"
sql = """
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
"""
params = {"schema": schema_name, "table": table}
elif sb.db_type == "mysql":
db_name = database or "information_schema"
sql = """
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :database
AND table_name = :table
"""
params = {"database": db_name, "table": table}
elif sb.db_type == "sqlserver":
schema_name = schema or "dbo"
sql = """
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
"""
params = {"schema": schema_name, "table": table}
elif sb.db_type == "oracle":
owner = (schema or "").upper()
sql = """
SELECT column_name
FROM all_tab_columns
WHERE owner = :schema
AND table_name = :table
"""
params = {"schema": owner, "table": table.upper()}
else:
return []
exec_db = None
if adapter.is_external and adapter.db_type == "postgresql":
exec_db = (database or "").strip() or self._resolve_exec_database()
result = await adapter.execute_query(sql, params, database=exec_db)
return [row["column_name"] for row in result]
except Exception as e:
logger.error(f"获取表列信息失败: {str(e)}")
return []
async def _check_column_exists(
self,
db: AsyncSession,
table: str,
schema: Optional[str],
database: Optional[str],
column_name: str,
*,
sql_builder: Optional[DynamicSQLBuilder] = None,
db_adapter: Optional[FormDataDbAdapter] = None,
) -> bool:
"""检查表中是否存在指定列"""
columns = await self._get_table_columns(
db, table, schema, database,
sql_builder=sql_builder, db_adapter=db_adapter,
)
return column_name in columns
async def _validate_sort_field(
self,
db: AsyncSession,
table: str,
schema: Optional[str],
database: Optional[str],
sort_field: str
) -> Optional[str]:
"""验证排序字段是否存在于表中,如果不存在则返回 None"""
try:
# 构建表名
table_name = self.sql_builder.build_table_name(table, schema, database)
# 获取表的列信息
columns = await self._get_table_columns(db, table, schema, database)
if not columns:
# 无法获取列信息,直接返回字段(不验证)
return sort_field
# 检查排序字段是否存在
if sort_field in columns:
return sort_field
logger.warning(f"排序字段 {sort_field} 不存在于表 {table_name} 中,可用字段: {columns}")
return None
except Exception as e:
logger.error(f"验证排序字段失败: {str(e)}")
# 验证失败时返回 None,使用默认排序
return None
# ============ 查询操作 ============
async def list(
self,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
filters: Dict[str, Any] = None,
sort_list: List[Dict[str, str]] = None,
data_scope: Dict[str, Any] = None,
search: str = None,
search_fields: List[str] = None
) -> Dict[str, Any]:
"""分页查询列表(仅主表,支持数据权限过滤)
Args:
sort_list: 排序列表,格式为 [{"field": "name", "order": "asc"}, ...]
data_scope: 数据权限过滤配置
search: 搜索关键词
search_fields: 搜索字段列表(多字段模糊搜索,OR 关系)
"""
import time
_t0 = time.perf_counter()
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# 应用数据权限过滤(先检查字段是否存在)
if data_scope:
filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope)
_t1 = time.perf_counter()
logger.info(f"[list 耗时] 数据权限过滤: {(_t1 - _t0) * 1000:.1f}ms")
# 处理关联字段的名称搜索(转换为 ID 搜索)
if filters:
filters = await self._convert_relation_name_to_id(db, filters)
# 转换日期字符串为 date 对象(PostgreSQL asyncpg 需要)
filters = self._convert_date_strings(filters)
_t2 = time.perf_counter()
logger.info(f"[list 耗时] 关联字段转换+日期转换: {(_t2 - _t1) * 1000:.1f}ms")
# 处理多字段搜索(OR 关系)
if search and search_fields:
if not filters:
filters = {}
# 使用特殊的 __search__ 键来标记多字段搜索
filters['__search__'] = {
'keyword': search,
'fields': search_fields
}
# 构建 ORDER BY(支持多字段排序)
order_by = None
if sort_list:
order_clauses = []
for sort_item in sort_list:
sort_field = sort_item.get('field')
sort_order = sort_item.get('order', 'desc')
if not sort_field:
continue
# 验证排序字段是否存在于表中
valid_sort_field = await self._validate_sort_field(db, table, schema, database, sort_field)
if valid_sort_field:
direction = "DESC" if sort_order.lower() == "desc" else "ASC"
order_clauses.append(f"{self.sql_builder.quote_identifier(valid_sort_field)} {direction}")
else:
logger.warning(f"排序字段 {sort_field} 不存在于表 {table} 中,跳过")
if order_clauses:
order_by = ", ".join(order_clauses)
_t3 = time.perf_counter()
logger.info(f"[list 耗时] 排序字段验证: {(_t3 - _t2) * 1000:.1f}ms")
# 查询总数
count_sql, count_params = self.sql_builder.build_count(
table, schema, database, filters
)
main_exec_db = self._table_exec_database(database)
count_result = await self._execute_query(
db, count_sql, count_params, database=main_exec_db
)
total = count_result[0]["total"] if count_result else 0
_t4 = time.perf_counter()
logger.info(f"[list 耗时] COUNT 查询 (total={total}): {(_t4 - _t3) * 1000:.1f}ms | SQL: {count_sql}")
# 查询数据
offset = (page - 1) * page_size
data_sql, data_params = self.sql_builder.build_select(
table=table,
schema=schema,
database=database,
where=filters,
order_by=order_by,
limit=page_size,
offset=offset
)
rows = await self._execute_query(
db, data_sql, data_params, database=main_exec_db
)
_t5 = time.perf_counter()
logger.info(f"[list 耗时] 数据查询 (rows={len(rows)}): {(_t5 - _t4) * 1000:.1f}ms | SQL: {data_sql}")
# 处理特殊类型
items = [self._serialize_row(row) for row in rows]
_t6 = time.perf_counter()
logger.info(f"[list 耗时] 序列化: {(_t6 - _t5) * 1000:.1f}ms")
# 填充关联字段的显示名称
relation_fields = self._get_relation_fields()
logger.debug(f"识别到的关联字段: {relation_fields}")
if relation_fields and items:
items = await self._fill_relation_display_names(db, items, relation_fields)
_t7 = time.perf_counter()
logger.info(f"[list 耗时] 填充关联字段显示名称 (fields={len(relation_fields)}): {(_t7 - _t6) * 1000:.1f}ms")
# 填充虚拟字段的值(基于值关联配置)
items = await self._fill_virtual_fields(db, items, relation_fields, context="list")
_t8 = time.perf_counter()
logger.info(f"[list 耗时] 填充虚拟字段: {(_t8 - _t7) * 1000:.1f}ms")
# 应用字段权限过滤
items = await self.apply_field_permissions(items, db)
_t9 = time.perf_counter()
logger.info(f"[list 耗时] 字段权限过滤: {(_t9 - _t8) * 1000:.1f}ms")
logger.info(f"[list 耗时] ===== 总耗时: {(_t9 - _t0) * 1000:.1f}ms (table={table}, page={page}, page_size={page_size}, total={total}) =====")
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size
}
async def list_cursor(
self,
db: AsyncSession,
page_size: int = 20,
cursor: Optional[str] = None,
cursor_direction: str = "next",
filters: Dict[str, Any] = None,
sort_list: List[Dict[str, str]] = None,
data_scope: Dict[str, Any] = None,
search: str = None,
search_fields: List[str] = None
) -> Dict[str, Any]:
"""游标分页查询列表(跳过 COUNT,使用 keyset pagination"""
import base64
import time
_t0 = time.perf_counter()
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
if data_scope:
filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope)
if filters:
filters = await self._convert_relation_name_to_id(db, filters)
filters = self._convert_date_strings(filters)
if search and search_fields:
if not filters:
filters = {}
filters['__search__'] = {
'keyword': search,
'fields': search_fields
}
# 构建排序字段列表 [(field, "ASC"|"DESC"), ...]
order_fields: List[Tuple[str, str]] = []
if sort_list:
for sort_item in sort_list:
sort_field = sort_item.get('field')
sort_order = sort_item.get('order', 'desc')
if not sort_field:
continue
valid_sort_field = await self._validate_sort_field(db, table, schema, database, sort_field)
if valid_sort_field:
direction = "DESC" if sort_order.lower() == "desc" else "ASC"
order_fields.append((valid_sort_field, direction))
# 确保 id 作为 tie-breaker 保证排序唯一性
id_already = any(f == "id" for f, _ in order_fields)
if not id_already:
last_dir = order_fields[-1][1] if order_fields else "DESC"
order_fields.append(("id", last_dir))
if not order_fields:
order_fields = [("id", "DESC")]
# 解码游标
cursor_values = None
if cursor:
try:
padding = 4 - len(cursor) % 4
if padding != 4:
cursor += "=" * padding
cursor_json = base64.urlsafe_b64decode(cursor).decode("utf-8")
cursor_data = json.loads(cursor_json)
cursor_values = cursor_data.get("v", {})
# asyncpg 要求 datetime/date 列传入原生对象而非字符串
if cursor_values:
for k, v in cursor_values.items():
if not isinstance(v, str):
continue
try:
parsed = datetime.fromisoformat(v)
cursor_values[k] = parsed if "T" in v or " " in v else parsed.date()
except (ValueError, TypeError):
pass
except Exception as e:
logger.warning(f"游标解码失败: {e}, 忽略游标从头查询")
cursor_values = None
_t1 = time.perf_counter()
data_sql, data_params = self.sql_builder.build_cursor_select(
table=table,
schema=schema,
database=database,
where=filters,
order_fields=order_fields,
cursor_values=cursor_values,
direction=cursor_direction,
limit=page_size
)
rows = await self._execute_query(db, data_sql, data_params)
_t2 = time.perf_counter()
logger.info(f"[list_cursor 耗时] 数据查询 (rows={len(rows)}): {(_t2 - _t1) * 1000:.1f}ms | SQL: {data_sql}")
# 判断 has_more:查询了 page_size + 1 条
has_more = len(rows) > page_size
if has_more:
rows = rows[:page_size]
# prev 方向查询时结果是反序的,需要翻转回来
if cursor_direction == "prev":
rows = list(reversed(rows))
items = [self._serialize_row(row) for row in rows]
relation_fields = self._get_relation_fields()
if relation_fields and items:
items = await self._fill_relation_display_names(db, items, relation_fields)
items = await self._fill_virtual_fields(db, items, relation_fields, context="list")
items = await self.apply_field_permissions(items, db)
# 编码游标
def _encode_cursor(row_data: Dict[str, Any]) -> Optional[str]:
if not row_data:
return None
v = {}
for field, _ in order_fields:
val = row_data.get(field)
if isinstance(val, (datetime, date)):
val = val.isoformat()
elif isinstance(val, Decimal):
val = float(val)
v[field] = val
cursor_obj = json.dumps({"v": v}, ensure_ascii=False, default=str)
return base64.urlsafe_b64encode(cursor_obj.encode("utf-8")).decode("utf-8").rstrip("=")
next_cursor = None
prev_cursor = None
if items:
# 用原始行数据(序列化前)生成游标,确保值精确
last_raw = rows[-1] if rows else None
first_raw = rows[0] if rows else None
if cursor_direction == "prev":
last_raw = rows[0] if rows else None
first_raw = rows[-1] if rows else None
if has_more:
if cursor_direction == "next":
next_cursor = _encode_cursor(dict(last_raw) if last_raw else None)
else:
prev_cursor = _encode_cursor(dict(first_raw) if first_raw else None)
# 始终提供反方向游标(如果有游标传入,说明可以往回翻)
if cursor:
if cursor_direction == "next":
prev_cursor = _encode_cursor(dict(rows[0]) if rows else None)
if has_more:
next_cursor = _encode_cursor(dict(rows[-1]) if rows else None)
else:
next_cursor = None
else:
next_cursor = _encode_cursor(dict(rows[-1]) if rows else None)
if has_more:
prev_cursor = _encode_cursor(dict(rows[0]) if rows else None)
else:
prev_cursor = None
else:
# 第一页:没有 prev_cursor
prev_cursor = None
if has_more:
next_cursor = _encode_cursor(dict(rows[-1]) if rows else None)
_t3 = time.perf_counter()
logger.info(f"[list_cursor 耗时] ===== 总耗时: {(_t3 - _t0) * 1000:.1f}ms (table={table}, page_size={page_size}, rows={len(items)}) =====")
return {
"items": items,
"has_more": has_more if cursor_direction == "next" else (cursor is not None),
"next_cursor": next_cursor,
"prev_cursor": prev_cursor,
"page_size": page_size
}
async def get_tree_children(
self,
db: AsyncSession,
parent_id: str = None,
parent_field: str = "parent_id",
data_scope: Dict[str, Any] = None
) -> List[Dict[str, Any]]:
"""获取树形数据的子节点(用于懒加载模式)
Args:
parent_id: 父节点ID,为空时获取根节点
parent_field: 父节点字段名,默认为 parent_id
data_scope: 数据权限过滤配置
Returns:
子节点列表,每个节点包含 has_children 标记
"""
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# 验证 parent_field 是否存在
valid_parent_field = await self._validate_sort_field(db, table, schema, database, parent_field)
if not valid_parent_field:
raise ColumnNotFoundError(column=parent_field, table=table)
# 构建过滤条件
filters = {}
if parent_id:
# 获取指定父节点的子节点
filters[parent_field] = {"type": "eq", "value": parent_id}
else:
# 获取根节点(parent_field 为空或 NULL
filters[parent_field] = {"type": "null"}
# 应用数据权限过滤
if data_scope:
filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope)
# 查询数据(按 sort 字段排序)
order_by = f"{self.sql_builder.quote_identifier('sort')} ASC"
data_sql, data_params = self.sql_builder.build_select(
table=table,
schema=schema,
database=database,
where=filters,
order_by=order_by
)
rows = await self._execute_query(db, data_sql, data_params)
items = [self._serialize_row(row) for row in rows]
# 填充关联字段的显示名称
relation_fields = self._get_relation_fields()
if relation_fields and items:
items = await self._fill_relation_display_names(db, items, relation_fields)
# 为每个节点检查是否有子节点
if items:
table_name = self.sql_builder.build_table_name(table, schema, database)
quoted_parent_field = self.sql_builder.quote_identifier(parent_field)
# 检查表是否有 is_deleted 字段
has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted")
# 批量查询所有节点的子节点数量
ids = [item.get('id') for item in items if item.get('id')]
if ids:
# 构建 IN 查询
placeholders = ", ".join([f":id_{i}" for i in range(len(ids))])
where_clause = f"{quoted_parent_field} IN ({placeholders})"
if has_is_deleted:
where_clause += self.sql_builder.is_deleted_and_clause()
count_sql = f"""
SELECT {quoted_parent_field} as parent_id, COUNT(*) as child_count
FROM {table_name}
WHERE {where_clause}
GROUP BY {quoted_parent_field}
"""
count_params = {f"id_{i}": id_val for i, id_val in enumerate(ids)}
try:
count_result = await self._execute_query(db, count_sql, count_params)
# 构建父ID到子节点数量的映射
child_count_map = {row['parent_id']: row['child_count'] for row in count_result}
# 为每个节点设置 has_children 标记
for item in items:
item_id = item.get('id')
item['has_children'] = child_count_map.get(item_id, 0) > 0
except Exception as e:
logger.warning(f"查询子节点数量失败: {e}")
# 如果查询失败,默认设置为 False
for item in items:
item['has_children'] = False
return items
async def get_field_values(
self,
db: AsyncSession,
field_name: str,
page: int = 1,
page_size: int = 20,
search: str = None
) -> Dict[str, Any]:
"""
获取指定字段的唯一值列表(用于过滤选项)
Args:
db: 数据库会话
field_name: 字段名
page: 页码
page_size: 每页数量
search: 搜索关键词(模糊匹配)
Returns:
{
"items": [{"value": "", "label": "显示文本", "count": 数量}],
"total": 总数,
"hasMore": 是否有更多
}
"""
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# 验证字段是否存在
valid_field = await self._validate_sort_field(db, table, schema, database, field_name)
if not valid_field:
raise ColumnNotFoundError(column=field_name, table=table)
# 构建表名
table_name = self.sql_builder.build_table_name(table, schema, database)
quoted_field = self.sql_builder.quote_identifier(field_name)
# 构建 WHERE 条件(只检查 NOT NULL,不检查空字符串,因为非字符串类型会报错)
where_clause = f"{quoted_field} IS NOT NULL"
params = {}
if search:
if self.sql_builder.db_type == "postgresql":
where_clause += f" AND CAST({quoted_field} AS TEXT) ILIKE :search"
else:
where_clause += (
f" AND LOWER(CAST({quoted_field} AS VARCHAR(4000))) "
f"LIKE LOWER(:search)"
)
params["search"] = f"%{search}%"
# 检查表是否有 is_deleted 字段
has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted")
if has_is_deleted:
where_clause += self.sql_builder.is_deleted_and_clause()
# 查询总数
count_sql = f"""
SELECT COUNT(DISTINCT {quoted_field}) as total
FROM {table_name}
WHERE {where_clause}
"""
count_result = await self._execute_query(db, count_sql, params)
total = count_result[0]["total"] if count_result else 0
# 查询唯一值(带分页)
offset = (page - 1) * page_size
data_sql = f"""
SELECT {quoted_field} as value, COUNT(*) as count
FROM {table_name}
WHERE {where_clause}
GROUP BY {quoted_field}
ORDER BY count DESC, {quoted_field} ASC
"""
data_sql = self.sql_builder._append_limit_offset(data_sql, page_size, offset)
rows = await self._execute_query(db, data_sql, params)
# 处理结果,获取显示标签
items = []
field_options = self._get_field_options(field_name)
for row in rows:
value = row["value"]
# 序列化特殊类型
if isinstance(value, (datetime, date)):
value = value.isoformat()
elif isinstance(value, Decimal):
value = float(value)
elif isinstance(value, uuid.UUID):
value = str(value)
# 获取显示标签
label = str(value)
if field_options:
for opt in field_options:
if str(opt.get("value")) == str(value):
label = opt.get("label", str(value))
break
items.append({
"value": value,
"label": label,
"count": row["count"]
})
return {
"items": items,
"total": total,
"hasMore": (page * page_size) < total
}
def _get_unique_check_fields(self) -> List[str]:
"""
从表单配置中提取启用了唯一性校验的主表字段名列表
支持两种配置方式:
1. 表单设计器中的 props.uniqueCheck(字段级别)
2. 数据库设计中的 tableConfigs[].fields[].uniqueCheck(表级别)
"""
unique_fields = set()
form_config = self._form_config
# 方式1:从表单设计器的 items 中获取
items = form_config.get("items", [])
def traverse(item_list: List[Dict], in_sub_table: str = None):
for item in item_list:
item_type = item.get("type", "")
field = item.get("field", "")
if item_type == "sub-table":
traverse(item.get("children", []), field)
continue
if item_type in ("grid", "tabs", "collapse", "steps", "divider", "alert", "timeline", "text", "html", "spacer", "title", "button"):
if item.get("columns"):
for col in item["columns"]:
traverse(col.get("children", []), in_sub_table)
if item.get("items"):
for sub_item in item["items"]:
traverse(sub_item.get("children", []), in_sub_table)
continue
if field and in_sub_table is None:
props = item.get("props", {})
if props.get("uniqueCheck"):
unique_fields.add(field)
traverse(items)
# 方式2:从数据库设计的 tableConfigs 中获取(主表字段)
table_configs = form_config.get("tableConfigs", [])
for table_config in table_configs:
if table_config.get("type") == "main":
for field in table_config.get("fields", []):
if field.get("uniqueCheck"):
unique_fields.add(field.get("name"))
return list(unique_fields)
async def _validate_unique_fields(
self,
db: AsyncSession,
data: Dict[str, Any],
exclude_id: str = None
) -> None:
"""
批量校验数据中启用了唯一性校验的字段,不通过则抛出 FormDataException
Args:
db: 数据库会话
data: 待写入的主表数据
exclude_id: 编辑时排除的记录ID
"""
unique_fields = self._get_unique_check_fields()
if not unique_fields:
return
duplicate_fields = []
for field_name in unique_fields:
value = data.get(field_name)
if value is None or value == '':
continue
is_unique = await self.check_unique(db, field_name, str(value), exclude_id)
if not is_unique:
duplicate_fields.append(field_name)
if duplicate_fields:
field_labels = self._get_field_labels(duplicate_fields)
messages = [f"'{field_labels.get(f, f)}'" for f in duplicate_fields]
raise UniqueConstraintError(
f"以下字段的值已存在: {', '.join(messages)}",
fields=duplicate_fields,
)
def _get_field_labels(self, field_names: List[str]) -> Dict[str, str]:
"""获取字段名到标签的映射"""
labels = {}
form_config = self._form_config
items = form_config.get("items", [])
def traverse(item_list: List[Dict]):
for item in item_list:
field = item.get("field", "")
label = item.get("label", "")
if field and label and field in field_names:
labels[field] = label
if item.get("columns"):
for col in item["columns"]:
traverse(col.get("children", []))
if item.get("items"):
for sub_item in item["items"]:
traverse(sub_item.get("children", []))
if item.get("children"):
traverse(item["children"])
traverse(items)
return labels
async def check_unique(
self,
db: AsyncSession,
field_name: str,
value: str,
exclude_id: str = None
) -> bool:
"""
检查字段值在主表中是否唯一
Args:
db: 数据库会话
field_name: 字段名
value: 字段值
exclude_id: 排除的记录ID(编辑时排除自身)
Returns:
True 表示唯一(可用),False 表示已存在
"""
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# 验证字段是否存在
valid_field = await self._validate_sort_field(db, table, schema, database, field_name)
if not valid_field:
raise ColumnNotFoundError(column=field_name, table=table)
# 构建查询
table_name = self.sql_builder.build_table_name(table, schema, database)
quoted_field = self.sql_builder.quote_identifier(field_name)
where_clause = f"{quoted_field} = :value"
params: Dict[str, Any] = {"value": value}
# 编辑时排除自身
if exclude_id:
quoted_id = self.sql_builder.quote_identifier("id")
where_clause += f" AND {quoted_id} != :exclude_id"
params["exclude_id"] = exclude_id
# 检查 is_deleted 字段
has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted")
if has_is_deleted:
where_clause += self.sql_builder.is_deleted_and_clause()
sql = f"""
SELECT COUNT(*) as cnt
FROM {table_name}
WHERE {where_clause}
"""
result = await self._execute_query(db, sql, params)
count = result[0]["cnt"] if result else 0
return count == 0
def _get_field_options(self, field_name: str) -> Optional[List[Dict]]:
"""从表单配置中获取字段的选项列表"""
form_config = self._form_config
items = form_config.get("items", [])
def find_field(item_list: List[Dict]) -> Optional[List[Dict]]:
for item in item_list:
if item.get("field") == field_name:
return item.get("options")
# 递归查找子项
for key in ["children", "columns", "items"]:
if key in item:
sub_items = item[key]
if isinstance(sub_items, list):
if key == "columns":
for col in sub_items:
if "children" in col:
result = find_field(col["children"])
if result is not None:
return result
else:
result = find_field(sub_items)
if result is not None:
return result
return None
return find_field(items)
async def get(self, db: AsyncSession, pk: Any) -> Dict[str, Any]:
"""获取单条数据(含子表)"""
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# 查询主表
sql, params = self.sql_builder.build_select(
table=table,
schema=schema,
database=database,
where={"id": pk}
)
rows = await self._execute_query(db, sql, params)
if not rows:
raise RecordNotFoundException(pk, table=table)
result = self._serialize_row(rows[0])
# 查询子表数据
result["sub_tables"] = {}
for sub_table in self.sub_tables:
sub_data = await self._query_sub_table_data(db, sub_table, pk)
result["sub_tables"][sub_table.table_name] = sub_data
# 填充关联字段的显示名称和虚拟字段
relation_fields = self._get_relation_fields()
all_relation_fields = self._get_all_selector_relation_fields(relation_fields)
if all_relation_fields:
await self._fill_relation_display_names(db, [result], all_relation_fields)
filled = await self._fill_virtual_fields(db, [result], relation_fields, context="form")
if filled:
result = filled[0]
# 应用字段权限过滤
result = await self.apply_field_permissions(result, db)
return result
async def _query_sub_table_data(
self,
db: AsyncSession,
sub_table: FormSubTable,
main_pk: Any
) -> List[Dict[str, Any]]:
"""查询子表数据"""
sql, params = self.sql_builder.build_select(
table=sub_table.table_name,
schema=sub_table.table_schema or None,
database=sub_table.table_database or None,
where={sub_table.foreign_key: main_pk}
)
sub_db = sub_table.table_database or None
rows = await self._execute_query(
db, sql, params, database=self._table_exec_database(sub_db)
)
return [self._serialize_row(row) for row in rows]
def _is_uuid_like(self, value: str) -> bool:
"""检查值是否像 UUID(用于判断是 ID 还是名称)"""
if not isinstance(value, str):
return False
# UUID 格式: 8-4-4-4-12 或 32位无连字符
import re
uuid_pattern = re.compile(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{32}$',
re.IGNORECASE
)
return bool(uuid_pattern.match(value))
def _is_date_string(self, value: str) -> bool:
"""检查值是否为日期字符串格式 YYYY-MM-DD"""
if not isinstance(value, str):
return False
import re
return bool(re.match(r'^\d{4}-\d{2}-\d{2}$', value))
def _is_datetime_string(self, value: str) -> bool:
"""检查值是否为日期时间字符串格式 YYYY-MM-DD HH:MM:SS 或 ISO 格式"""
if not isinstance(value, str):
return False
import re
# 支持多种格式:YYYY-MM-DD HH:MM:SS, YYYY-MM-DDTHH:MM:SS, 带毫秒和时区
return bool(re.match(r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', value))
def _parse_datetime_string(self, value: str) -> Optional[datetime]:
"""解析日期时间字符串为 datetime 对象"""
if not isinstance(value, str):
return None
# 尝试多种格式
formats = [
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d",
]
# 处理带时区的格式
clean_value = value
if value.endswith('Z'):
clean_value = value[:-1]
elif '+' in value[-6:] or (value.count('-') > 2 and ':' in value[-6:]):
# 移除时区信息
clean_value = value.rsplit('+', 1)[0].rsplit('-', 1)[0] if '+' in value[-6:] else value
for fmt in formats:
try:
return datetime.strptime(clean_value, fmt)
except ValueError:
continue
return None
@staticmethod
def _convert_bool_string(value: str):
"""将字符串 'true'/'false' 转换为 Python bool,非布尔字符串返回 None"""
if value.lower() == 'true':
return True
if value.lower() == 'false':
return False
return None
def _convert_date_strings(self, filters: Dict[str, Any]) -> Dict[str, Any]:
"""将过滤器中的日期/日期时间/布尔字符串转换为原生类型(PostgreSQL asyncpg 需要)"""
if not filters:
return filters
converted = {}
for field, value in filters.items():
if isinstance(value, dict):
filter_type = value.get("type")
filter_value = value.get("value")
# 处理布尔字符串
if filter_type in ("eq",) and isinstance(filter_value, str):
bool_val = self._convert_bool_string(filter_value)
if bool_val is not None:
converted[field] = {"type": filter_type, "value": bool_val}
continue
# 处理 gte, lte, gt, lt, eq 等类型的日期/日期时间值
# 注意:如果 case_sensitive 明确设为 false,说明是文本比较,不转换日期
case_sensitive = value.get("case_sensitive", True)
if filter_type in ("gte", "lte", "gt", "lt", "eq") and isinstance(filter_value, str) and case_sensitive:
if self._is_datetime_string(filter_value):
# 优先尝试解析为 datetime
parsed = self._parse_datetime_string(filter_value)
if parsed:
converted[field] = {"type": filter_type, "value": parsed}
else:
converted[field] = value
elif self._is_date_string(filter_value):
# 对于 datetime 字段使用日期格式查询时,需要特殊处理
# lte/lt: 使用当天的 23:59:59.999999(包含当天所有时间)
# gte/gt: 使用当天的 00:00:00
try:
parsed_date = date.fromisoformat(filter_value)
if filter_type == "lte":
# 小于等于:转换为当天结束时间
end_of_day = datetime.combine(parsed_date, datetime.max.time())
converted[field] = {"type": filter_type, "value": end_of_day}
elif filter_type == "lt":
# 小于:转换为当天开始时间(不包含当天)
start_of_day = datetime.combine(parsed_date, datetime.min.time())
converted[field] = {"type": filter_type, "value": start_of_day}
elif filter_type == "gte":
# 大于等于:转换为当天开始时间
start_of_day = datetime.combine(parsed_date, datetime.min.time())
converted[field] = {"type": filter_type, "value": start_of_day}
elif filter_type == "gt":
# 大于:转换为当天结束时间(不包含当天)
end_of_day = datetime.combine(parsed_date, datetime.max.time())
converted[field] = {"type": filter_type, "value": end_of_day}
elif filter_type == "eq":
# 精确匹配日期:转换为当天范围查询(00:00:00 - 23:59:59
# 这样 2026-01-14 可以匹配 2026-01-14 16:16:09
start_of_day = datetime.combine(parsed_date, datetime.min.time())
end_of_day = datetime.combine(parsed_date, datetime.max.time())
converted[field] = {"type": "range", "value": [start_of_day, end_of_day]}
else:
converted[field] = {"type": filter_type, "value": parsed_date}
except ValueError:
converted[field] = value
else:
converted[field] = value
# 处理 in 类型的日期值列表
elif filter_type == "in" and isinstance(filter_value, list):
converted_values = []
for v in filter_value:
if isinstance(v, str):
if self._is_datetime_string(v):
parsed = self._parse_datetime_string(v)
converted_values.append(parsed if parsed else v)
elif self._is_date_string(v):
try:
converted_values.append(date.fromisoformat(v))
except ValueError:
converted_values.append(v)
else:
converted_values.append(v)
else:
converted_values.append(v)
converted[field] = {"type": filter_type, "value": converted_values}
# 处理 range 类型
elif filter_type == "range" and isinstance(filter_value, list) and len(filter_value) == 2:
converted_values = []
for idx, v in enumerate(filter_value):
if isinstance(v, str):
if self._is_datetime_string(v):
parsed = self._parse_datetime_string(v)
converted_values.append(parsed if parsed else v)
elif self._is_date_string(v):
# 对于 range 查询,第一个值(开始)使用当天开始,第二个值(结束)使用当天结束
try:
parsed_date = date.fromisoformat(v)
if idx == 0:
# 开始日期:当天 00:00:00
start_of_day = datetime.combine(parsed_date, datetime.min.time())
converted_values.append(start_of_day)
else:
# 结束日期:当天 23:59:59.999999
end_of_day = datetime.combine(parsed_date, datetime.max.time())
converted_values.append(end_of_day)
except ValueError:
converted_values.append(v)
else:
converted_values.append(v)
else:
converted_values.append(v)
converted[field] = {"type": filter_type, "value": converted_values}
else:
converted[field] = value
elif isinstance(value, str):
# 简单等值条件:布尔字符串
bool_val = self._convert_bool_string(value)
if bool_val is not None:
converted[field] = bool_val
# 简单等值条件的日期/日期时间字符串
elif self._is_datetime_string(value):
parsed = self._parse_datetime_string(value)
converted[field] = parsed if parsed else value
elif self._is_date_string(value):
try:
converted[field] = date.fromisoformat(value)
except ValueError:
converted[field] = value
else:
converted[field] = value
else:
converted[field] = value
return converted
async def _convert_form_data_filter_to_ids(
self,
db: AsyncSession,
relation_config: Dict[str, str],
value: Any,
) -> Any:
"""将表单选择器字段的显示名筛选转为关联表主键 ID(支持第三方库)。"""
form_code = relation_config.get("form_code")
display_column = relation_config.get("display_column", "name")
relation_key = relation_config.get("relation_key", "id")
if not form_code:
return value
is_list, parsed = self._parse_list_value(value)
if is_list and parsed and all(self._is_uuid_like(v) for v in parsed):
if len(parsed) == 1:
return parsed[0]
return {"type": "in", "value": parsed}
if isinstance(value, list) and all(self._is_uuid_like(v) for v in value):
if len(value) == 1:
return value[0]
return {"type": "in", "value": value}
if isinstance(value, str) and self._is_uuid_like(value):
return value
from online_dev.form_manager.model import FormMeta as RelatedFormMeta
stmt = select(RelatedFormMeta).where(
RelatedFormMeta.code == form_code,
RelatedFormMeta.is_deleted == False,
)
result = await db.execute(stmt)
related_form = result.scalar_one_or_none()
if not related_form:
return value
rel_adapter, rel_builder = await resolve_form_sql_context(
db,
related_form,
adapter_cache=self._adapter_cache,
builder_cache=self._sql_builder_cache,
)
table = related_form.main_table
schema = await self._resolve_schema(db, related_form.main_table_schema) or None
rel_database = related_form.main_table_database or None
table_name = rel_builder.build_table_name(table, schema, rel_database)
key_col = rel_builder.quote_identifier(relation_key)
display_col = rel_builder.quote_identifier(display_column)
rel_exec_db = self._table_exec_database(rel_database)
has_is_deleted = await self._check_column_exists(
db,
table,
related_form.main_table_schema,
related_form.main_table_database,
"is_deleted",
sql_builder=rel_builder,
db_adapter=rel_adapter,
)
deleted_clause = (
f" AND {rel_builder.is_deleted_predicate()}" if has_is_deleted else ""
)
if isinstance(value, dict) and "type" in value:
filter_type = value.get("type")
filter_value = value.get("value")
if filter_type in ("space_like_and", "space_like_or", "space_eq_and", "space_eq_or"):
return value
if filter_type == "in" and isinstance(filter_value, list):
if all(self._is_uuid_like(v) for v in filter_value):
return value
if filter_type == "like":
search_sql = (
f"SELECT {key_col} as id FROM {table_name} "
f"WHERE {display_col} LIKE :search_value{deleted_clause}"
)
params = {"search_value": f"%{filter_value}%"}
else:
case_sensitive = value.get("case_sensitive", True)
if case_sensitive:
where_clause = f"{display_col} = :search_value"
elif rel_builder.db_type == "postgresql":
where_clause = (
f"CAST({display_col} AS TEXT) ILIKE :search_value"
)
else:
where_clause = (
f"LOWER(CAST({display_col} AS VARCHAR(4000))) "
f"= LOWER(:search_value)"
)
search_sql = (
f"SELECT {key_col} as id FROM {table_name} "
f"WHERE {where_clause}{deleted_clause}"
)
params = {"search_value": filter_value}
else:
if rel_builder.db_type == "postgresql":
where_clause = f"CAST({display_col} AS TEXT) ILIKE :search_value"
else:
where_clause = f"{display_col} LIKE :search_value"
search_sql = (
f"SELECT {key_col} as id FROM {table_name} "
f"WHERE {where_clause}{deleted_clause}"
)
params = {"search_value": f"%{value}%"}
try:
rows = await rel_adapter.execute_query(
search_sql, params, database=rel_exec_db
)
except Exception as e:
logger.error("表单关联字段名称转 ID 失败 [%s]: %s", form_code, e)
return value
if not rows:
return "00000000-0000-0000-0000-000000000000"
ids = [row["id"] for row in rows]
if len(ids) == 1:
return ids[0]
return {"type": "in", "value": ids}
async def _convert_relation_name_to_id(self, db: AsyncSession, filters: Dict[str, Any]) -> Dict[str, Any]:
"""将关联字段的名称搜索转换为 ID 搜索,或直接使用 ID 列表"""
if not filters:
return filters
# 获取关联字段配置
relation_fields = self._get_relation_fields()
if not relation_fields:
return filters
converted_filters = {}
for field, value in filters.items():
# 检查是否是关联字段
if field in relation_fields:
relation_config = relation_fields[field]
relation_type = relation_config.get("relation_type", "")
if relation_type == "form_data":
try:
converted_filters[field] = await self._convert_form_data_filter_to_ids(
db, relation_config, value
)
except Exception as e:
logger.error(f"转换表单关联字段 {field} 搜索失败: {e}")
converted_filters[field] = value
continue
relation_table = relation_config.get("relation_table")
display_column = relation_config.get("display_column", "name")
if not relation_table:
converted_filters[field] = value
continue
try:
is_list, parsed = self._parse_list_value(value)
if is_list and parsed and all(self._is_uuid_like(v) for v in parsed):
if len(parsed) == 1:
converted_filters[field] = parsed[0]
else:
converted_filters[field] = {"type": "in", "value": parsed}
continue
if isinstance(value, list) and all(self._is_uuid_like(v) for v in value):
if len(value) == 1:
converted_filters[field] = value[0]
else:
converted_filters[field] = {"type": "in", "value": value}
continue
# 检查是否是单个 UUID(直接传递的 ID)
if isinstance(value, str) and self._is_uuid_like(value):
converted_filters[field] = value
continue
# 根据名称查询平台关联表获取 ID(始终在平台 PG)
pb = self._platform_sql_builder
table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA)
# 处理不同的过滤类型
if isinstance(value, dict) and "type" in value:
filter_type = value.get("type")
filter_value = value.get("value")
# 空格搜索类型是纯文本搜索,不需要转换关联字段
if filter_type in ("space_like_and", "space_like_or", "space_eq_and", "space_eq_or"):
converted_filters[field] = value
continue
# 如果 filter_value 是 ID 列表,直接使用
if filter_type == "in" and isinstance(filter_value, list):
if all(self._is_uuid_like(v) for v in filter_value):
converted_filters[field] = value
continue
if filter_type == "like":
query_sql = f"""
SELECT {pb.quote_identifier("id")}
FROM {table_name}
WHERE {pb.quote_identifier(display_column)} LIKE :search_value
AND {pb.is_deleted_predicate()}
"""
params = {"search_value": f"%{filter_value}%"}
else:
case_sensitive = value.get("case_sensitive", True)
if case_sensitive:
where_clause = f"{pb.quote_identifier(display_column)} = :search_value"
else:
where_clause = (
f"LOWER({pb.quote_identifier(display_column)}) "
f"= LOWER(:search_value)"
)
query_sql = f"""
SELECT {pb.quote_identifier("id")}
FROM {table_name}
WHERE {where_clause}
AND {pb.is_deleted_predicate()}
"""
params = {"search_value": filter_value}
else:
query_sql = f"""
SELECT {pb.quote_identifier("id")}
FROM {table_name}
WHERE {pb.quote_identifier(display_column)} LIKE :search_value
AND {pb.is_deleted_predicate()}
"""
params = {"search_value": f"%{value}%"}
result = await self._execute_platform_query(db, query_sql, params)
if result:
# 找到匹配的 ID,使用 ID 进行搜索
ids = [row["id"] for row in result]
if len(ids) == 1:
converted_filters[field] = ids[0]
else:
# 多个匹配,使用 IN 查询
converted_filters[field] = {"type": "in", "value": ids}
else:
# 没有找到匹配的名称,返回一个不存在的 ID(确保搜索结果为空)
converted_filters[field] = "00000000-0000-0000-0000-000000000000"
except Exception as e:
logger.error(f"转换关联字段 {field} 搜索失败: {e}")
converted_filters[field] = value
else:
# 非关联字段,保持原样
converted_filters[field] = value
return converted_filters
def _get_relation_fields(self) -> Dict[str, Dict[str, str]]:
"""从表单配置中提取需要关联查询的字段"""
relation_fields = {}
list_config = self._list_config
columns = list_config.get("columns", [])
form_config = self._form_config
logger.debug(f"表单配置 form_config 类型: {type(form_config)}")
logger.debug(f"表单配置内容: {form_config}")
logger.debug(f"表单配置 items 数量: {len(form_config.get('items', []))}")
logger.debug(f"列表配置 columns 数量: {len(columns)}")
# 构建字段到组件配置的映射
field_to_component = {}
def traverse_items(items):
"""递归遍历表单项,构建字段映射"""
if not items:
return
for item in items:
item_type = item.get("type", "")
field = item.get("field", "")
# 容器类型,递归处理子项
if item_type == "grid":
for col in item.get("columns", []):
traverse_items(col.get("children", []))
elif item_type in ("collapse", "steps"):
for panel in item.get("items", []):
traverse_items(panel.get("children", []))
elif item_type == "tabs":
for panel in item.get("children", []):
traverse_items(panel.get("children", []))
elif item_type == "sub-table":
traverse_items(item.get("children", []))
elif field:
# 保存字段对应的组件配置
field_to_component[field] = item
logger.debug(f"找到字段: {field}, 类型: {item_type}")
traverse_items(form_config.get("items", []))
logger.debug(f"构建的字段映射: {list(field_to_component.keys())}")
# 遍历列表配置,查找需要关联查询的字段
for col in columns:
field = col.get("field")
display_field = col.get("displayField")
if not field or not display_field:
continue
logger.debug(f"检查字段 {field}displayField: {display_field}")
# 从表单配置中找到对应的组件
component = field_to_component.get(field)
if not component:
# 组件可能在未遍历到的容器中,尝试从列配置的保存信息中恢复
if col.get("isFormDataSelector") and col.get("formCode"):
display_field_name = col.get("displayFieldName", "")
form_code = col.get("formCode", "")
relation_key = col.get("valueField", "id") or "id"
display_column = display_field_name or col.get("labelField", "name") or "name"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": relation_key,
"display_column": display_column,
}
logger.debug(f"字段 {field} 组件未找到,从列配置恢复: formCode={form_code}, displayColumn={display_column}")
else:
logger.warning(f"字段 {field} 在表单配置中未找到对应组件")
continue
# 获取组件的 props
props = component.get("props", {})
logger.debug(f"字段 {field} 的组件类型: {component.get('type')}, props: {props}")
# 从 props 中提取关联表信息
relation_table = props.get("relationTable")
relation_key = props.get("relationKey", "id")
display_column = props.get("displayColumn", "name")
# 如果 props 中没有配置,尝试根据组件类型和字段名推断
if not relation_table:
component_type = component.get("type", "")
# 根据组件类型推断
type_mapping = {
"department-selector": "core_dept",
"dept-selector": "core_dept",
"dept-select": "core_dept",
"user-selector": "core_user",
"user-select": "core_user",
"position-selector": "core_post",
"post-selector": "core_post",
"role-selector": "core_role",
"org-selector": "core_dept",
"region-selector": "core_region",
}
relation_table = type_mapping.get(component_type)
# 如果还是没有,根据字段名推断
if not relation_table:
field_mapping = {
"dept_id": "core_dept",
"department_id": "core_dept",
"user_id": "core_user",
"manger_id": "core_user",
"manager_id": "core_user",
"position_id": "core_post",
"positon_id": "core_post",
}
relation_table = field_mapping.get(field)
if relation_table:
relation_fields[field] = {
"display_field": display_field,
"relation_table": relation_table,
"relation_key": relation_key,
"display_column": display_column,
}
logger.debug(f"字段 {field} 配置了关联表: {relation_table}")
else:
# 检查是否为表单数据选择器(select 或 table-selector 组件,数据源类型为 formData)
# 或者是新的 form-selector 组件
component_type = component.get("type", "")
# form-selector 组件的配置存储在 formSelectorConfig 中
form_selector_config = component.get("formSelectorConfig") or {}
# 数据源配置存储在 dataSource 中,而不是 props 中
data_source = component.get("dataSource") or {}
data_source_type = data_source.get("type", "") if data_source else ""
if not data_source_type:
data_source_type = props.get("dataSourceType", "")
# 获取 formCode:优先从 formSelectorConfig 获取,其次从 dataSource 获取
form_code = form_selector_config.get("formCode", "") or data_source.get("formCode", "") or props.get("formCode", "")
display_field_name = col.get("displayFieldName", "") # 从列表配置中获取要显示的字段名
# form-selector 组件直接处理
if component_type == "form-selector" and form_code:
relation_key = form_selector_config.get("valueField", "id") or "id"
label_field = form_selector_config.get("labelField", "name") or "name"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": relation_key,
"display_column": display_field_name or label_field,
}
logger.debug(f"字段 {field} 配置了表单选择器关联: formCode={form_code}, relationKey={relation_key}, displayColumn={display_field_name or label_field}")
elif component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"] and data_source_type == "formData" and form_code:
relation_key = data_source.get("formValueField") or data_source.get("valueField", "id") or "id"
label_field = data_source.get("formLabelField") or data_source.get("labelField", "name") or "name"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": relation_key,
"display_column": display_field_name or label_field,
}
logger.debug(f"字段 {field} 配置了表单数据关联: formCode={form_code}, relationKey={relation_key}, displayColumn={display_field_name or label_field}")
else:
logger.warning(f"字段 {field} 未配置 relationTable 且无法自动推断")
# 添加系统字段的关联配置(这些字段不在表单配置中,但需要关联查询)
system_field_mapping = {
"sys_creator_id": {"relation_table": "core_user", "display_column": "name"},
"sys_modifier_id": {"relation_table": "core_user", "display_column": "name"},
"sys_dept_id": {"relation_table": "core_dept", "display_column": "name"},
}
for col in columns:
field = col.get("field")
display_field = col.get("displayField")
if field in system_field_mapping and display_field:
config = system_field_mapping[field]
relation_fields[field] = {
"display_field": display_field,
"relation_table": config["relation_table"],
"relation_key": "id",
"display_column": config["display_column"],
}
logger.debug(f"系统字段 {field} 配置了关联表: {config['relation_table']}")
# 识别没有 displayField 的数据源字段(字典 / 表单数据),原地替换 value 为 label
column_fields = {col.get("field") for col in columns if col.get("field")}
for field, component in field_to_component.items():
if field in relation_fields or field not in column_fields:
continue
data_source = component.get("dataSource") or {}
ds_type = data_source.get("type", "")
if ds_type == "dict" and data_source.get("dictCode"):
display_field = f"{field}_label"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "dict",
"dict_code": data_source["dictCode"],
}
logger.debug(f"字段 {field} 配置了字典数据源: dictCode={data_source['dictCode']}, displayField={display_field}")
elif ds_type == "formData" and data_source.get("formCode"):
component_type = component.get("type", "")
if component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"]:
form_code = data_source["formCode"]
relation_key = data_source.get("formValueField") or data_source.get("valueField", "id") or "id"
label_field = data_source.get("formLabelField") or data_source.get("labelField", "name") or "name"
display_field = f"{field}_name"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": relation_key,
"display_column": label_field,
}
logger.debug(f"字段 {field} 无 displayField,自动补充表单数据关联: formCode={form_code}, displayField={display_field}")
elif ds_type == "dataSource" and data_source.get("dataSourceCode"):
display_field = f"{field}_label"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "data_source",
"data_source_code": data_source["dataSourceCode"],
"value_field": data_source.get("valueField") or "value",
"label_field": data_source.get("labelField") or "label",
}
# 静态选项(select/radio/checkbox/cascader/tree-select 等组件的硬编码 options
elif component.get("type") in ["select", "radio", "checkbox", "cascader", "tree-select"] and component.get("options"):
if not ds_type or ds_type == "static":
display_field = f"{field}_label"
relation_fields[field] = {
"display_field": display_field,
"relation_type": "static",
"options": component.get("options", []),
}
logger.debug(f"字段 {field} 配置了静态选项: displayField={display_field}, 选项数={len(component.get('options', []))}")
return relation_fields
def _get_all_selector_relation_fields(self, existing: Dict[str, Dict[str, str]]) -> Dict[str, Dict[str, str]]:
"""从 form_config 中提取所有选择器字段的关联配置,补充 _get_relation_fields 可能遗漏的字段。
_get_relation_fields 依赖 list_config.columns,不在列表中的选择器字段不会被识别。
此方法直接从 form_config.items 遍历所有选择器组件,确保 get() 上下文下也能填充显示名。
"""
result = dict(existing)
form_config = self._form_config
system_type_mapping = {
"dept-selector": "core_dept",
"user-selector": "core_user",
"role-selector": "core_role",
"post-selector": "core_post",
"region-selector": "core_region",
}
def traverse(items):
if not items:
return
for item in items:
t = item.get("type", "")
field = item.get("field", "")
if t == "grid":
for col in item.get("columns", []):
traverse(col.get("children", []))
elif t in ("collapse", "steps"):
for panel in item.get("items", []):
traverse(panel.get("children", []))
elif t == "tabs":
for panel in item.get("children", []):
traverse(panel.get("children", []))
elif t == "sub-table":
traverse(item.get("children", []))
elif field and field not in result:
if t in system_type_mapping:
display_field = field.replace("_id", "_name") if field.endswith("_id") else f"{field}_name"
result[field] = {
"display_field": display_field,
"relation_table": system_type_mapping[t],
"relation_key": "id",
"display_column": "name",
}
elif t == "form-selector":
fsc = item.get("formSelectorConfig") or {}
form_code = fsc.get("formCode") or item.get("props", {}).get("formCode") or ""
if form_code:
label_field = fsc.get("labelField") or item.get("props", {}).get("labelField") or "name"
value_field = fsc.get("valueField") or "id"
if label_field not in ("name", "label"):
display_field = f"{field}_{label_field}"
elif field.endswith("_id"):
display_field = field.replace("_id", "_name")
else:
display_field = f"{field}_name"
result[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": value_field,
"display_column": label_field,
}
elif t == "table-selector":
ds = item.get("dataSource") or {}
form_code = ds.get("formCode") or item.get("props", {}).get("formCode") or ""
if form_code:
label_field = ds.get("formLabelField") or item.get("props", {}).get("labelField") or "label"
value_field = ds.get("formValueField") or ds.get("valueField") or "id"
if label_field not in ("name", "label"):
display_field = f"{field}_{label_field}"
elif field.endswith("_id"):
display_field = field.replace("_id", "_name")
else:
display_field = f"{field}_name"
result[field] = {
"display_field": display_field,
"relation_type": "form_data",
"form_code": form_code,
"relation_key": value_field,
"display_column": label_field,
}
# 静态选项(select/radio/checkbox/cascader/tree-select 的硬编码 options
elif t in ["select", "radio", "checkbox", "cascader", "tree-select"]:
ds = item.get("dataSource") or {}
ds_type = ds.get("type", "")
options = item.get("options", [])
if options and (not ds_type or ds_type == "static"):
display_field = f"{field}_label" if not field.endswith("_id") else field.replace("_id", "_label")
result[field] = {
"display_field": display_field,
"relation_type": "static",
"options": options,
}
traverse(form_config.get("items", []))
return result
async def _fill_relation_display_names(
self,
db: AsyncSession,
items: List[Dict[str, Any]],
relation_fields: Dict[str, Dict[str, str]]
) -> List[Dict[str, Any]]:
"""填充关联字段的显示名称"""
for field, config in relation_fields.items():
relation_type = config.get("relation_type", "")
relation_table = config.get("relation_table", "")
# 表单数据选择器特殊处理
if relation_type == "form_data":
try:
await self._fill_form_data_display_field(db, items, field, config)
except Exception as e:
logger.warning(f"填充表单数据字段 {field} 失败: {str(e)}")
try:
await db.rollback()
except Exception:
pass
continue
if relation_type == "data_source":
code = config.get("data_source_code")
value_field = config.get("value_field", "value")
label_field = config.get("label_field", "label")
display_field = config.get("display_field", f"{field}_label")
if not code:
continue
try:
from core.data_source.service import DataSourceService
rows = await DataSourceService.execute(db, code, {})
if not isinstance(rows, list):
rows = []
value_to_label = {}
for row in rows:
if isinstance(row, dict):
vk = row.get(value_field)
if vk is not None:
value_to_label[str(vk)] = row.get(label_field, vk)
for item in items:
v = item.get(field)
if v is not None:
is_list, parsed = self._parse_list_value(v)
if is_list:
item[display_field] = [
value_to_label.get(str(x), x) for x in parsed
]
else:
item[display_field] = value_to_label.get(str(v), v)
except Exception as e:
logger.warning(f"填充平台数据源字段 {field} 失败: {str(e)}")
continue
# 字典数据源:批量查询 dict_item,将 label 写入显示字段(不覆盖原值)
if relation_type == "dict":
dict_code = config.get("dict_code")
if not dict_code:
continue
try:
values = set()
for item in items:
v = item.get(field)
if v is not None:
is_list, parsed = self._parse_list_value(v)
if is_list:
values.update(str(x) for x in parsed if x is not None)
else:
values.add(str(v))
if not values:
continue
value_to_label = await self._query_dict_labels(db, dict_code, list(values))
# 确定显示字段名:优先使用配置的 display_field,否则自动生成 {field}_label
display_field = config.get("display_field", f"{field}_label")
for item in items:
v = item.get(field)
if v is not None:
is_list, parsed = self._parse_list_value(v)
if is_list:
item[display_field] = [value_to_label.get(str(x), x) for x in parsed]
else:
item[display_field] = value_to_label.get(str(v), v)
logger.debug(f"字典字段 {field} 翻译完成: dictCode={dict_code}, 映射数={len(value_to_label)}, displayField={display_field}")
except Exception as e:
logger.warning(f"填充字典字段 {field} 失败: {str(e)}")
continue
# 静态选项:从 options 中查找 label
if relation_type == "static":
options = config.get("options", [])
if not options:
continue
display_field = config.get("display_field", f"{field}_label")
try:
value_to_label = {}
for opt in options:
opt_value = opt.get("value")
if isinstance(opt_value, bool) or opt_value is not None:
value_to_label[str(opt_value)] = opt.get("label", str(opt_value))
for item in items:
v = item.get(field)
if v is not None:
is_list, parsed = self._parse_list_value(v)
if is_list:
item[display_field] = [value_to_label.get(str(x), x) for x in parsed]
else:
item[display_field] = value_to_label.get(str(v), v)
logger.debug(f"静态选项字段 {field} 翻译完成: displayField={display_field}, 选项数={len(options)}")
except Exception as e:
logger.warning(f"填充静态选项字段 {field} 失败: {str(e)}")
continue
# 如果没有关联表,跳过
if not relation_table:
logger.warning(f"字段 {field} 没有配置关联表,跳过")
continue
# 省市区组件特殊处理
if relation_table == "core_region":
try:
codes = set()
for item in items:
is_list, parsed = self._parse_list_value(item.get(field))
if is_list:
codes.update(str(v) for v in parsed if v)
if not codes:
continue
code_to_name = await self._query_region_names(db, list(codes))
display_field = config["display_field"]
for item in items:
is_list, parsed = self._parse_list_value(item.get(field))
if is_list:
names = [
code_to_name.get(str(c), str(c)) for c in parsed if c
]
item[display_field] = " / ".join(names)
except Exception as e:
logger.warning(f"填充省市区字段 {field} 失败: {str(e)}")
continue
elif relation_table:
# 其他关联组件的处理逻辑(用户、部门等)
ids = self._collect_field_ids(items, field)
if not ids:
continue
logger.info(f"准备查询关联字段 {field},关联表: {relation_table}IDs: {ids}")
try:
# 批量查询关联数据
relation_data = await self._query_relation_data(
db,
config["relation_table"],
config["relation_key"],
config["display_column"],
list(ids)
)
# 构建 ID 到名称的映射
id_to_name = {str(row["id"]): row["name"] for row in relation_data}
display_field = config["display_field"]
logger.info(f"字段 {field} 的 ID 到名称映射: {id_to_name}")
for item in items:
self._set_display_from_id_map(item, field, display_field, id_to_name)
except Exception as e:
logger.warning(f"填充关联字段 {field} 失败: {str(e)}")
# 回滚事务,避免影响后续查询
try:
await db.rollback()
except Exception:
pass
# 填充失败不影响主流程,继续处理其他字段
continue
return items
@staticmethod
def _parse_list_value(v: Any) -> Tuple[bool, list]:
"""将字段值解析为列表。
级联/多选组件的值可能是 Python listjson/jsonb 列)、JSON 字符串(varchar/text 列),
或历史数据中 Python str(list) 产生的单引号格式(如 "['a', 'b']")。
返回 (is_list, parsed_list);非列表值返回 (False, [])。
"""
if isinstance(v, list):
return True, v
if isinstance(v, str) and v.startswith("["):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return True, parsed
except (json.JSONDecodeError, ValueError):
pass
# 兼容历史数据:str(list) 产生的单引号格式,如 "['a', 'b']"
try:
import ast
parsed = ast.literal_eval(v)
if isinstance(parsed, list):
return True, parsed
except (ValueError, SyntaxError):
pass
return False, []
@staticmethod
def _collect_field_ids(items: List[Dict[str, Any]], field: str) -> set:
"""从列表行中收集关联字段 ID(兼容 JSON 字符串形式的多选值)。"""
ids: set = set()
for item in items:
v = item.get(field)
if v is None:
continue
is_list, parsed = FormDataService._parse_list_value(v)
if is_list:
ids.update(str(x) for x in parsed if x is not None)
else:
ids.add(str(v))
return ids
@staticmethod
def _set_display_from_id_map(
item: Dict[str, Any],
field: str,
display_field: str,
id_to_name: Dict[str, str],
) -> None:
"""将 ID(或 ID 列表)映射为显示名称写入 display_field。"""
value = item.get(field)
if not value:
return
is_list, parsed = FormDataService._parse_list_value(value)
if is_list:
names = [id_to_name.get(str(v), str(v)) for v in parsed if v]
item[display_field] = ", ".join(names)
else:
item[display_field] = id_to_name.get(str(value), str(value))
async def _query_dict_labels(self, db: AsyncSession, dict_code: str, values: List[str]) -> Dict[str, str]:
"""批量查询字典项的 value->label 映射"""
from core.dict.model import Dict as DictModel
from core.dict_item.model import DictItem
stmt = (
select(DictItem.value, DictItem.label)
.join(DictModel, DictItem.dict_id == DictModel.id)
.where(
DictModel.code == dict_code,
DictItem.value.in_(values),
DictItem.is_deleted == False,
)
)
result = await db.execute(stmt)
return {row.value: row.label for row in result}
async def _query_form_data_labels(
self,
db: AsyncSession,
field: str,
config: Dict[str, Any],
items: List[Dict[str, Any]]
) -> Dict[str, str]:
"""查询关联表单数据,返回 value->label 映射(用于无 displayField 的表单数据源字段)"""
form_code = config.get("form_code")
relation_key = config.get("relation_key", "id")
display_column = config.get("display_column", "name")
# 收集所有不重复的 value(兼容 JSON 字符串数组)
ids = set()
for item in items:
v = item.get(field)
if v is not None:
is_list, parsed = self._parse_list_value(v)
if is_list:
ids.update(str(x) for x in parsed if x is not None)
else:
ids.add(str(v))
if not ids or not form_code:
return {}
from online_dev.form_manager.model import FormMeta as RelatedFormMeta
stmt = select(RelatedFormMeta).where(
RelatedFormMeta.code == form_code,
RelatedFormMeta.is_deleted == False
)
result = await db.execute(stmt)
related_form = result.scalar_one_or_none()
if not related_form:
logger.warning(f"关联表单 {form_code} 不存在")
return {}
rel_adapter, rel_builder = await resolve_form_sql_context(
db,
related_form,
adapter_cache=self._adapter_cache,
builder_cache=self._sql_builder_cache,
)
table = related_form.main_table
schema = await self._resolve_schema(db, related_form.main_table_schema) or None
database = related_form.main_table_database or None
table_name = rel_builder.build_table_name(table, schema, database)
key_col = rel_builder.quote_identifier(relation_key)
display_col = rel_builder.quote_identifier(display_column)
has_is_deleted = await self._check_column_exists(
db,
table,
related_form.main_table_schema,
related_form.main_table_database,
"is_deleted",
sql_builder=rel_builder,
db_adapter=rel_adapter,
)
ids_list = list(ids)
placeholders = ", ".join([f":{i}" for i in range(len(ids_list))])
is_deleted_clause = (
f"\n AND {rel_builder.is_deleted_predicate()}"
if has_is_deleted
else ""
)
sql = f"""
SELECT {key_col} as id, {display_col} as name
FROM {table_name}
WHERE {key_col} IN ({placeholders}){is_deleted_clause}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
rel_exec_db = (database or "").strip() or None
rows = await rel_adapter.execute_query(sql, params, database=rel_exec_db)
return {str(row["id"]): str(row["name"]) for row in rows}
async def _fill_virtual_fields(
self,
db: AsyncSession,
items: List[Dict[str, Any]],
relation_fields: Dict[str, Dict[str, str]],
context: str = "list"
) -> List[Dict[str, Any]]:
"""填充虚拟字段的值
虚拟字段通过值关联配置(isVirtualField=true),从源字段的关联数据中
提取指定属性填充到结果中,不对应数据库列。
Args:
db: 数据库会话
items: 数据列表
relation_fields: 已识别的关联字段配置(来自 _get_relation_fields
context: 调用上下文,'list' 列表接口(受 showVirtualValue 开关控制),'form' 表单详情(始终填充)
"""
if not items:
return items
# 从 form_config 中提取虚拟字段配置
virtual_fields = self._get_virtual_fields()
if not virtual_fields:
return items
# 构建 list_config 中虚拟字段的 showVirtualValue 映射
list_config = self._list_config
columns = list_config.get("columns", [])
col_show_map = {}
for col in columns:
if col.get("isVirtualField"):
col_show_map[col.get("field", "")] = col.get("showVirtualValue", True)
logger.info(f"识别到的虚拟字段: {virtual_fields}")
for vf in virtual_fields:
vf_field = vf["field"] # 虚拟字段名
source_field = vf["valueSourceField"] # 源字段名
display_field = vf["valueDisplayField"] # 要提取的属性名
# 仅在列表上下文中检查 showVirtualValue 开关,表单详情始终填充
if context == "list" and not col_show_map.get(vf_field, True):
logger.info(f"虚拟字段 {vf_field} 的显示关联值已关闭,跳过")
continue
# 从 relation_fields 中获取源字段的关联配置
source_config = relation_fields.get(source_field)
# 如果源字段不在 relation_fields 中(可能未添加到列表列),从 form_config 推断
if not source_config:
source_config = self._infer_relation_config(source_field)
if not source_config:
logger.warning(f"虚拟字段 {vf_field} 的源字段 {source_field} 没有关联配置,跳过")
continue
relation_type = source_config.get("relation_type", "")
relation_table = source_config.get("relation_table", "")
relation_key = source_config.get("relation_key", "id")
# 省市区组件不支持虚拟字段
if relation_table == "core_region":
logger.warning(f"虚拟字段 {vf_field} 的源字段 {source_field} 是省市区组件,不支持虚拟字段")
continue
ids = self._collect_field_ids(items, source_field)
if not ids:
continue
try:
id_to_value = {}
if relation_type == "form_data":
# 表单数据选择器:查询关联表单的数据表
form_code = source_config.get("form_code")
if not form_code:
continue
from online_dev.form_manager.model import FormMeta as RelatedFormMeta
stmt = select(RelatedFormMeta).where(
RelatedFormMeta.code == form_code,
RelatedFormMeta.is_deleted == False
)
result = await db.execute(stmt)
related_form = result.scalar_one_or_none()
if not related_form:
logger.warning(f"虚拟字段 {vf_field} 关联表单 {form_code} 不存在")
continue
rel_adapter, rel_builder = await resolve_form_sql_context(
db,
related_form,
adapter_cache=self._adapter_cache,
builder_cache=self._sql_builder_cache,
)
table = related_form.main_table
schema = await self._resolve_schema(db, related_form.main_table_schema) or None
database = related_form.main_table_database or None
table_name = rel_builder.build_table_name(table, schema, database)
key_col = rel_builder.quote_identifier(relation_key)
val_col = rel_builder.quote_identifier(display_field)
has_is_deleted = await self._check_column_exists(
db,
table,
related_form.main_table_schema,
related_form.main_table_database,
"is_deleted",
sql_builder=rel_builder,
db_adapter=rel_adapter,
)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
ids_list = list(ids)
is_deleted_clause = (
f"\n AND {rel_builder.is_deleted_predicate()}"
if has_is_deleted
else ""
)
sql = f"""
SELECT {key_col} as id, {val_col} as val
FROM {table_name}
WHERE {key_col} IN ({placeholders}){is_deleted_clause}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
rel_exec_db = (database or "").strip() or None
rows = await rel_adapter.execute_query(
sql, params, database=rel_exec_db
)
id_to_value = {str(row["id"]): row["val"] for row in rows}
else:
if not relation_table:
continue
pb = self._platform_sql_builder
table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA)
key_col = pb.quote_identifier(relation_key)
val_col = pb.quote_identifier(display_field)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
ids_list = list(ids)
sql = f"""
SELECT {key_col} as id, {val_col} as val
FROM {table_name}
WHERE {key_col} IN ({placeholders})
AND {pb.is_deleted_predicate()}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
async with db.begin_nested():
rows = await self._execute_platform_query(db, sql, params)
id_to_value = {str(row["id"]): row["val"] for row in rows}
for item in items:
source_value = item.get(source_field)
if not source_value:
item[vf_field] = ""
continue
is_list, parsed = self._parse_list_value(source_value)
if is_list:
vals = [
id_to_value.get(str(v), "") for v in parsed if v
]
item[vf_field] = ", ".join(str(v) for v in vals if v)
else:
item[vf_field] = id_to_value.get(str(source_value), "")
except Exception as e:
logger.warning(f"填充虚拟字段 {vf_field} 失败: {str(e)}")
continue
return items
def _get_virtual_fields(self) -> List[Dict[str, str]]:
"""从 form_config 中提取虚拟字段配置
Returns:
虚拟字段配置列表,每项包含:
- field: 虚拟字段名
- valueSourceField: 源字段名
- valueDisplayField: 要提取的属性名
"""
virtual_fields = []
form_config = self._form_config
def traverse(items):
if not items:
return
for item in items:
item_type = item.get("type", "")
field = item.get("field", "")
props = item.get("props", {})
# 检查是否为虚拟字段
if (field and props.get("isVirtualField") and
props.get("enableValueLink") and
props.get("valueSourceField") and
props.get("valueDisplayField")):
virtual_fields.append({
"field": field,
"valueSourceField": props["valueSourceField"],
"valueDisplayField": props["valueDisplayField"],
})
# 递归处理容器
if item_type == "grid":
for col in item.get("columns", []):
traverse(col.get("children", []))
elif item_type in ("collapse", "steps"):
for panel in item.get("items", []):
traverse(panel.get("children", []))
elif item_type == "tabs":
for panel in item.get("children", []):
traverse(panel.get("children", []))
elif item_type == "sub-table":
traverse(item.get("children", []))
traverse(form_config.get("items", []))
return virtual_fields
def _infer_relation_config(self, source_field: str) -> Optional[Dict[str, str]]:
"""从 form_config 中推断源字段的关联表配置
当源字段未出现在 list_config.columns 中时,需要从 form_config 的组件类型推断关联信息。
Args:
source_field: 源字段名
Returns:
关联配置字典,包含 relation_table, relation_key, display_column 等,或 None
"""
form_config = self._form_config
# 递归查找源字段的组件配置
def find_component(items):
if not items:
return None
for item in items:
item_type = item.get("type", "")
field = item.get("field", "")
if field == source_field:
return item
if item_type == "grid":
for col in item.get("columns", []):
found = find_component(col.get("children", []))
if found:
return found
elif item_type in ("collapse", "steps"):
for panel in item.get("items", []):
found = find_component(panel.get("children", []))
if found:
return found
elif item_type == "tabs":
for panel in item.get("children", []):
found = find_component(panel.get("children", []))
if found:
return found
elif item_type == "sub-table":
found = find_component(item.get("children", []))
if found:
return found
return None
component = find_component(form_config.get("items", []))
if not component:
return None
component_type = component.get("type", "")
props = component.get("props", {})
# 根据组件类型推断关联表
type_mapping = {
"department-selector": "core_dept",
"dept-selector": "core_dept",
"dept-select": "core_dept",
"user-selector": "core_user",
"user-select": "core_user",
"position-selector": "core_post",
"post-selector": "core_post",
"role-selector": "core_role",
"org-selector": "core_dept",
"region-selector": "core_region",
}
relation_table = props.get("relationTable") or type_mapping.get(component_type)
if relation_table:
return {
"relation_table": relation_table,
"relation_key": props.get("relationKey", "id"),
"display_column": "name",
}
# form-selector 组件
if component_type == "form-selector":
form_selector_config = component.get("formSelectorConfig") or {}
form_code = form_selector_config.get("formCode", "") or props.get("formCode", "")
if form_code:
return {
"relation_type": "form_data",
"form_code": form_code,
"relation_key": form_selector_config.get("valueField", "id") or "id",
"display_column": form_selector_config.get("labelField", "name") or "name",
}
# select/table-selector 组件,数据源为 formData
data_source = component.get("dataSource") or {}
data_source_type = data_source.get("type", "") or props.get("dataSourceType", "")
form_code = data_source.get("formCode", "") or props.get("formCode", "")
if component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"] and data_source_type == "formData" and form_code:
return {
"relation_type": "form_data",
"form_code": form_code,
"relation_key": data_source.get("formValueField") or data_source.get("valueField", "id") or "id",
"display_column": data_source.get("formLabelField") or data_source.get("labelField", "name") or "name",
}
# 根据字段名推断
field_mapping = {
"dept_id": "core_dept",
"department_id": "core_dept",
"user_id": "core_user",
"manger_id": "core_user",
"manager_id": "core_user",
"position_id": "core_post",
"positon_id": "core_post",
}
relation_table = field_mapping.get(source_field)
if relation_table:
return {
"relation_table": relation_table,
"relation_key": "id",
"display_column": "name",
}
return None
def _get_non_virtual_linked_fields(self) -> List[Dict[str, str]]:
"""从 form_config 中提取非虚拟的值关联字段配置
非虚拟关联字段(enableValueLink=true, isVirtualField=false 或未设置)
对应数据库列,需要在保存时将关联值写入数据库。
Returns:
配置列表,每项包含:
- field: 字段名(对应数据库列)
- valueSourceField: 源字段名
- valueDisplayField: 要从关联数据中提取的属性名
"""
linked_fields = []
form_config = self._form_config
def traverse(items):
if not items:
return
for item in items:
item_type = item.get("type", "")
field = item.get("field", "")
props = item.get("props", {})
if (field and not props.get("isVirtualField")
and props.get("enableValueLink")
and props.get("valueSourceField")
and props.get("valueDisplayField")):
linked_fields.append({
"field": field,
"valueSourceField": props["valueSourceField"],
"valueDisplayField": props["valueDisplayField"],
})
if item_type == "grid":
for col in item.get("columns", []):
traverse(col.get("children", []))
elif item_type in ("collapse", "steps"):
for panel in item.get("items", []):
traverse(panel.get("children", []))
elif item_type == "tabs":
for panel in item.get("children", []):
traverse(panel.get("children", []))
elif item_type == "sub-table":
traverse(item.get("children", []))
traverse(form_config.get("items", []))
return linked_fields
async def _fill_linked_field_values(
self,
db: AsyncSession,
data: Dict[str, Any],
table_type: str = "main"
) -> Dict[str, Any]:
"""保存前填充非虚拟关联字段的值
根据值关联配置,从源字段的值反查关联表,将结果写入对应的数据库列。
Args:
db: 数据库会话
data: 待保存的数据
table_type: "main""sub"
"""
linked_fields = self._get_non_virtual_linked_fields()
if not linked_fields:
return data
relation_fields = self._get_relation_fields()
for lf in linked_fields:
target_field = lf["field"]
source_field = lf["valueSourceField"]
display_field = lf["valueDisplayField"]
source_value = data.get(source_field)
if not source_value:
continue
source_config = relation_fields.get(source_field)
if not source_config:
source_config = self._infer_relation_config(source_field)
if not source_config:
logger.warning(f"非虚拟关联字段 {target_field} 的源字段 {source_field} 没有关联配置,跳过")
continue
relation_type = source_config.get("relation_type", "")
relation_table = source_config.get("relation_table", "")
relation_key = source_config.get("relation_key", "id")
if relation_table == "core_region":
continue
ids = set()
if isinstance(source_value, list):
ids.update(str(v) for v in source_value if v)
else:
ids.add(str(source_value))
if not ids:
continue
try:
id_to_value = {}
if relation_type == "form_data":
form_code = source_config.get("form_code")
if not form_code:
continue
from online_dev.form_manager.model import FormMeta as RelatedFormMeta
stmt = select(RelatedFormMeta).where(
RelatedFormMeta.code == form_code,
RelatedFormMeta.is_deleted == False
)
result = await db.execute(stmt)
related_form = result.scalar_one_or_none()
if not related_form:
logger.warning(f"关联字段 {target_field} 的关联表单 {form_code} 不存在")
continue
rel_adapter, rel_builder = await resolve_form_sql_context(
db,
related_form,
adapter_cache=self._adapter_cache,
builder_cache=self._sql_builder_cache,
)
table = related_form.main_table
schema = await self._resolve_schema(db, related_form.main_table_schema) or None
database = related_form.main_table_database or None
table_name = rel_builder.build_table_name(table, schema, database)
key_col = rel_builder.quote_identifier(relation_key)
val_col = rel_builder.quote_identifier(display_field)
has_is_deleted = await self._check_column_exists(
db,
table,
related_form.main_table_schema,
related_form.main_table_database,
"is_deleted",
sql_builder=rel_builder,
db_adapter=rel_adapter,
)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
ids_list = list(ids)
is_deleted_clause = (
f"\n AND {rel_builder.is_deleted_predicate()}"
if has_is_deleted
else ""
)
sql = f"""
SELECT {key_col} as id, {val_col} as val
FROM {table_name}
WHERE {key_col} IN ({placeholders}){is_deleted_clause}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
rel_exec_db = (database or "").strip() or None
rows = await rel_adapter.execute_query(
sql, params, database=rel_exec_db
)
id_to_value = {str(row["id"]): row["val"] for row in rows}
else:
if not relation_table:
continue
pb = self._platform_sql_builder
table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA)
key_col = pb.quote_identifier(relation_key)
val_col = pb.quote_identifier(display_field)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
ids_list = list(ids)
sql = f"""
SELECT {key_col} as id, {val_col} as val
FROM {table_name}
WHERE {key_col} IN ({placeholders})
AND {pb.is_deleted_predicate()}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
rows = await self._execute_platform_query(db, sql, params)
id_to_value = {str(row["id"]): row["val"] for row in rows}
if id_to_value:
if isinstance(source_value, list):
vals = [id_to_value.get(str(v), "") for v in source_value if v]
data[target_field] = ", ".join(str(v) for v in vals if v)
else:
resolved = id_to_value.get(str(source_value), "")
if resolved:
data[target_field] = resolved
logger.debug(f"填充非虚拟关联字段 {target_field}: source={source_field}, value={data.get(target_field)}")
except Exception as e:
logger.warning(f"填充非虚拟关联字段 {target_field} 失败: {str(e)}")
continue
return data
async def _fill_form_data_display_field(
self,
db: AsyncSession,
items: List[Dict[str, Any]],
field: str,
config: Dict[str, Any]
) -> None:
"""填充表单数据选择器的显示字段
Args:
db: 数据库会话
items: 数据列表
field: ID 字段名(如 customer_id
config: 配置信息,包含 form_code, display_field, display_column, relation_key
"""
form_code = config.get("form_code")
display_field = config.get("display_field")
display_column = config.get("display_column", "name")
relation_key = config.get("relation_key", "id")
if not form_code or not display_field:
return
ids = self._collect_field_ids(items, field)
if not ids:
return
try:
# 获取关联表单的元数据
from online_dev.form_manager.model import FormMeta as RelatedFormMeta
from sqlalchemy import select
stmt = select(RelatedFormMeta).where(
RelatedFormMeta.code == form_code,
RelatedFormMeta.is_deleted == False
)
result = await db.execute(stmt)
related_form = result.scalar_one_or_none()
if not related_form:
logger.warning(f"关联表单 {form_code} 不存在")
return
# 获取关联表单的表信息
table = related_form.main_table
schema = await self._resolve_schema(db, related_form.main_table_schema) or None
database = related_form.main_table_database or None
rel_adapter, rel_builder = await resolve_form_sql_context(
db,
related_form,
adapter_cache=self._adapter_cache,
builder_cache=self._sql_builder_cache,
)
table_name = rel_builder.build_table_name(table, schema, database)
key_col = rel_builder.quote_identifier(relation_key)
display_col = rel_builder.quote_identifier(display_column)
has_is_deleted = await self._check_column_exists(
db,
table,
related_form.main_table_schema,
related_form.main_table_database,
"is_deleted",
sql_builder=rel_builder,
db_adapter=rel_adapter,
)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
ids_list = list(ids)
is_deleted_clause = (
f"\n AND {rel_builder.is_deleted_predicate()}"
if has_is_deleted
else ""
)
sql = f"""
SELECT {key_col} as id, {display_col} as name
FROM {table_name}
WHERE {key_col} IN ({placeholders}){is_deleted_clause}
"""
params = {str(i): ids_list[i] for i in range(len(ids_list))}
rel_exec_db = (database or "").strip() or None
rows = await rel_adapter.execute_query(sql, params, database=rel_exec_db)
logger.debug(f"查询表单数据 {form_code}.{table},找到 {len(rows)} 条记录")
# 构建 ID 到名称的映射
id_to_name = {str(row["id"]): row["name"] for row in rows}
for item in items:
self._set_display_from_id_map(item, field, display_field, id_to_name)
except Exception as e:
logger.error(f"填充表单数据字段 {field} 失败: {str(e)}")
raise
async def _query_region_names(
self,
db: AsyncSession,
codes: List[str]
) -> Dict[str, str]:
"""批量查询省市区名称
省市区数据分散在 5 个表中:
- core_province: 2位代码(如 "14"
- core_city: 4位代码(如 "1404"
- core_area: 6位代码(如 "140406"
- core_street: 9位代码(如 "140406001"
- core_village: 12位代码(如 "140406001001"
Args:
db: 数据库会话
codes: 行政区划代码列表
Returns:
code 到 name 的映射字典
"""
if not codes:
return {}
result = {}
try:
# 按代码长度分组
codes_by_length = {}
for code in codes:
length = len(str(code))
if length not in codes_by_length:
codes_by_length[length] = []
codes_by_length[length].append(str(code))
# 定义表名映射(代码长度 -> 表名)
table_mapping = {
2: "core_province",
4: "core_city",
6: "core_area",
9: "core_street",
12: "core_village",
}
# 分别查询每个表
for length, code_list in codes_by_length.items():
table_name = table_mapping.get(length)
if not table_name:
logger.warning(f"未知的代码长度: {length},代码: {code_list}")
continue
pb = self._platform_sql_builder
full_table_name = pb.build_table_name(table_name, schema=PLATFORM_SCHEMA)
code_col = pb.quote_identifier("code")
name_col = pb.quote_identifier("name")
# 使用参数化查询
placeholders = ", ".join([f":{i}" for i in range(len(code_list))])
sql = f"""
SELECT {code_col} as code, {name_col} as name
FROM {full_table_name}
WHERE {code_col} IN ({placeholders})
"""
# 构建参数字典
params = {str(i): code_list[i] for i in range(len(code_list))}
rows = await self._execute_platform_query(db, sql, params)
logger.info(f"查询 {table_name},找到 {len(rows)} 条记录")
# 添加到结果映射
for row in rows:
result[str(row["code"])] = row["name"]
return result
except Exception as e:
logger.error(f"查询省市区名称失败: {str(e)}")
return {}
async def _query_relation_data(
self,
db: AsyncSession,
table: str,
key_column: str,
display_column: str,
ids: List[str]
) -> List[Dict[str, Any]]:
"""批量查询关联表数据"""
if not ids:
return []
pb = self._platform_sql_builder
table_name = pb.build_table_name(table, schema=PLATFORM_SCHEMA)
key_col = pb.quote_identifier(key_column)
display_col = pb.quote_identifier(display_column)
placeholders = ", ".join([f":{i}" for i in range(len(ids))])
sql = f"""
SELECT {key_col} as id, {display_col} as name
FROM {table_name}
WHERE {key_col} IN ({placeholders})
AND {pb.is_deleted_predicate()}
"""
params = {str(i): ids[i] for i in range(len(ids))}
try:
logger.info(f"查询关联表 {table}SQL: {sql}, params: {params}")
rows = await self._execute_platform_query(db, sql, params)
logger.info(f"查询关联表 {table},找到 {len(rows)} 条记录,结果: {rows}")
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"查询关联表 {table} 失败: {str(e)}, SQL: {sql}, params: {params}")
return []
# ============ 写入操作 ============
def _convert_data_types(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""转换数据类型,将字符串日期时间转换为 Python datetime 对象,处理空字符串"""
converted = {}
# 获取字段类型映射(用于判断数字类型字段)
field_types = self._get_field_types()
# 调试日志
import logging
logger = logging.getLogger(__name__)
# logger.info(f"字段类型映射: {field_types}")
# logger.info(f"待转换数据: {data}")
for key, value in data.items():
if value is None:
converted[key] = value
continue
# 处理空字符串
if isinstance(value, str) and value.strip() == '':
# 获取字段类型
field_type = field_types.get(key, '').lower()
# 数字类型字段的空字符串转为 None
if field_type in ('int', 'integer', 'bigint', 'smallint', 'decimal', 'numeric', 'float', 'double', 'real'):
converted[key] = None
continue
# 其他类型保持空字符串或转为 None
converted[key] = None
continue
# 获取字段类型
field_type = field_types.get(key, '').lower()
# 处理非字符串类型到字符串的转换
# 支持 PostgreSQL 的类型名:character varying, character, text 等
is_string_type = any(t in field_type for t in ['varchar', 'text', 'char', 'string'])
if not isinstance(value, str) and is_string_type:
if isinstance(value, datetime):
if value.tzinfo is not None:
value = value.astimezone(APP_TIMEZONE).replace(tzinfo=None)
converted[key] = value.isoformat()
logger.info(f"字段 {key} 从 datetime 转换为 str: {value} -> {converted[key]}")
elif isinstance(value, (list, dict)):
converted[key] = json.dumps(value, ensure_ascii=False)
logger.info(f"字段 {key}{type(value).__name__} 序列化为 JSON: {converted[key]}")
else:
converted[key] = str(value)
logger.info(f"字段 {key}{type(value).__name__} 转换为 str: {value} -> {str(value)}")
continue
# 处理已经是 datetime 对象的情况
if isinstance(value, datetime):
# 如果是 offset-aware datetime,先转换为配置的时区再移除时区信息
if value.tzinfo is not None:
value = value.astimezone(APP_TIMEZONE).replace(tzinfo=None)
# 检查字段类型是否是 timestamp/datetime 类型
is_datetime_type = any(t in field_type for t in ('date', 'datetime', 'timestamp', 'time'))
if is_datetime_type:
# 保持为 datetime 对象
converted[key] = value
else:
# 字段类型未知或是字符串类型,转换为 ISO 格式字符串
converted[key] = value.isoformat()
logger.info(f"字段 {key} 从 datetime 转换为 str (字段类型: {field_type}): {value} -> {converted[key]}")
continue
# 转换字符串类型的值
if isinstance(value, str):
# 1. 尝试转换布尔类型
if field_type in ('bool', 'boolean'):
# 支持多种布尔值表示
value_lower = value.lower().strip()
if value_lower in ('true', '1', 'yes', 'y', 't', '', ''):
converted[key] = True
continue
elif value_lower in ('false', '0', 'no', 'n', 'f', '', ''):
converted[key] = False
continue
# 其他值保持原样
# 2. 尝试转换整数类型
if field_type in ('int', 'integer', 'bigint', 'smallint'):
try:
# 先去除空格,支持 "25 " 这样的输入
converted[key] = int(value.strip())
continue
except (ValueError, TypeError):
pass
# 3. 尝试转换浮点数类型
elif field_type in ('decimal', 'numeric', 'float', 'double', 'real'):
try:
converted[key] = float(value.strip())
continue
except (ValueError, TypeError):
pass
# 4. 尝试解析日期时间(支持 'timestamp without time zone' 等完整类型名)
is_datetime_type = any(t in field_type for t in ('date', 'datetime', 'timestamp', 'time'))
if is_datetime_type:
try:
from datetime import time as time_type
# 时间格式
if field_type == 'time':
for fmt in ["%H:%M:%S", "%H:%M:%S.%f", "%H:%M"]:
try:
t = datetime.strptime(value, fmt).time()
converted[key] = t
break
except ValueError:
continue
else:
converted[key] = value
continue
# 先尝试使用 fromisoformat 解析(支持更多 ISO 格式)
parsed_dt = None
try:
# 处理 Z 后缀(UTC 时区标识)
iso_value = value.replace('Z', '+00:00') if value.endswith('Z') else value
parsed_dt = datetime.fromisoformat(iso_value)
except ValueError:
# fromisoformat 失败,尝试其他格式
for fmt in [
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d",
]:
try:
parsed_dt = datetime.strptime(value, fmt)
break
except ValueError:
continue
if parsed_dt is not None:
# 如果是日期格式(没有时间部分),只保留日期
if len(value) == 10 and '-' in value:
converted[key] = parsed_dt.date()
else:
# 将 UTC 时间转换为本地时间后再移除时区信息
if parsed_dt.tzinfo is not None:
# 转换为配置的时区
parsed_dt = parsed_dt.astimezone(APP_TIMEZONE)
# 移除时区信息
parsed_dt = parsed_dt.replace(tzinfo=None)
converted[key] = parsed_dt
else:
# 无法解析,保持原值
converted[key] = value
continue
except Exception:
converted[key] = value
continue
# 5. 尝试解析 JSON 类型(json, jsonb
if field_type in ('json', 'jsonb'):
try:
converted[key] = json.loads(value)
continue
except (json.JSONDecodeError, TypeError):
# 如果解析失败,保持原字符串
pass
# 6. 数组类型(PostgreSQL array
if field_type.endswith('[]') or field_type in ('array', 'text[]', 'varchar[]', 'integer[]'):
try:
parsed = json.loads(value)
if isinstance(parsed, list):
converted[key] = parsed
continue
except (json.JSONDecodeError, TypeError):
# 尝试按逗号分隔
if ',' in value:
converted[key] = [item.strip() for item in value.split(',')]
continue
# 默认保持原值
converted[key] = value
else:
converted[key] = value
return converted
def _get_field_types(self) -> Dict[str, str]:
"""获取字段名到类型的映射"""
field_types = {}
# 从 form_config 中获取字段类型
form_config = self._form_config
table_configs = form_config.get('tableConfigs', [])
for table_config in table_configs:
fields = table_config.get('fields', [])
for field in fields:
field_name = field.get('name', '')
field_type = field.get('type', '')
if field_name and field_type:
field_types[field_name] = field_type
return field_types
async def create(self, db: AsyncSession, data: Dict[str, Any]) -> Dict[str, Any]:
"""新增数据(含子表,事务)"""
main_data = data.get("main") or {}
sub_tables_data = data.get("sub_tables") or {}
field_perms = await self._get_merged_field_permissions(db)
# 过滤主表字段
allowed_main_fields = self._get_allowed_fields("main")
filtered_main = self._filter_writable_fields(main_data, allowed_main_fields, field_perms)
# 转换数据类型
filtered_main = self._convert_data_types(filtered_main)
# 移除 id 字段,生成新的 UUID
filtered_main.pop("id", None)
generated_id = str(uuid.uuid4())
filtered_main["id"] = generated_id
# 填充系统字段(创建时间、创建人、部门等)
filtered_main = self._fill_system_fields_for_create(filtered_main)
# 填充非虚拟关联字段的值(从源字段反查关联表写入数据库列)
filtered_main = await self._fill_linked_field_values(db, filtered_main, table_type="main")
if not filtered_main:
raise FormDataValidationError("主表数据不能为空")
# 唯一性校验(新增时不需要排除ID
await self._validate_unique_fields(db, filtered_main)
# 1. 插入主表
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
sql, params = self.sql_builder.build_insert(
table=table,
data=filtered_main,
schema=schema,
database=database,
return_id=False
)
main_pk = generated_id
async with self._business_transaction(database=self._table_exec_database(database)):
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
for sub_table in self.sub_tables:
sub_data_list = sub_tables_data.get(sub_table.table_name, [])
if not sub_data_list:
continue
allowed_sub_fields = self._get_allowed_fields("sub", sub_table.table_name)
for sub_item in sub_data_list:
filtered_sub = self._filter_writable_fields(
sub_item,
allowed_sub_fields,
field_perms,
always_allow={sub_table.foreign_key},
)
filtered_sub = self._convert_data_types(filtered_sub)
filtered_sub.pop("id", None)
filtered_sub["id"] = str(uuid.uuid4())
filtered_sub[sub_table.foreign_key] = main_pk
filtered_sub = self._fill_system_fields_for_create(filtered_sub)
filtered_sub = await self._fill_linked_field_values(
db, filtered_sub, table_type="sub"
)
if filtered_sub:
sub_sql, sub_params = self.sql_builder.build_insert(
table=sub_table.table_name,
data=filtered_sub,
schema=sub_table.table_schema or None,
database=sub_table.table_database or None,
return_id=False,
)
sub_db = sub_table.table_database or None
await self._execute_command(
db,
sub_sql,
sub_params,
database=self._table_exec_database(sub_db),
)
await db.commit()
logger.info(f"表单数据创建成功: form={self._form_code}, pk={main_pk}")
return await self.get(db, main_pk)
async def update(self, db: AsyncSession, pk: Any, data: Dict[str, Any]) -> Dict[str, Any]:
"""更新数据(含子表,事务)"""
main_data = data.get("main") or {}
sub_tables_data = data.get("sub_tables") or {}
field_perms = await self._get_merged_field_permissions(db)
await self.get(db, pk)
main_schema_db = self.form_meta.main_table_database or None
async with self._business_transaction(
database=self._table_exec_database(main_schema_db)
):
if main_data:
allowed_main_fields = self._get_allowed_fields("main")
filtered_main = self._filter_writable_fields(
main_data, allowed_main_fields, field_perms
)
filtered_main = self._convert_data_types(filtered_main)
filtered_main.pop("id", None)
filtered_main = self._fill_system_fields_for_update(filtered_main)
filtered_main = await self._fill_linked_field_values(
db, filtered_main, table_type="main"
)
await self._validate_unique_fields(db, filtered_main, exclude_id=str(pk))
if filtered_main:
table = self.form_meta.main_table
schema = await self._resolve_schema(
db, self.form_meta.main_table_schema
) or None
database = self.form_meta.main_table_database or None
sql, params = self.sql_builder.build_update(
table=table,
data=filtered_main,
pk_field="id",
pk_value=pk,
schema=schema,
database=database,
)
await self._execute_command(
db,
sql,
params,
database=self._table_exec_database(database),
)
for sub_table in self.sub_tables:
if sub_table.table_name not in sub_tables_data:
continue
new_sub_data = sub_tables_data[sub_table.table_name]
await self._handle_sub_table_update(
db, sub_table, pk, new_sub_data, field_perms
)
await db.commit()
logger.info(f"表单数据更新成功: form={self._form_code}, pk={pk}")
return await self.get(db, pk)
async def _handle_sub_table_update(
self,
db: AsyncSession,
sub_table: FormSubTable,
main_pk: Any,
new_data: List[Dict[str, Any]],
field_perms: Optional[Dict[str, Dict]] = None,
):
"""处理子表更新(差异对比:新增/更新/删除)"""
table_name = sub_table.table_name
schema = sub_table.table_schema or None
database = sub_table.table_database or None
foreign_key = sub_table.foreign_key
# 获取现有数据
existing = await self._query_sub_table_data(db, sub_table, main_pk)
existing_map = {item["id"]: item for item in existing if "id" in item}
allowed_fields = self._get_allowed_fields("sub", table_name)
field_perms = field_perms or {}
writable_allow = {foreign_key}
# 分类处理
new_ids = set()
to_insert = []
to_update = []
for item in new_data:
item_id = item.get("id")
if item_id and item_id in existing_map:
new_ids.add(item_id)
to_update.append(item)
elif not item_id:
to_insert.append(item)
else:
to_insert.append(item)
# 找出需要删除的
to_delete = [eid for eid in existing_map.keys() if eid not in new_ids]
# 执行删除
for del_id in to_delete:
sql, params = self.sql_builder.build_delete(
table=table_name,
pk_field="id",
pk_value=del_id,
schema=schema,
database=database
)
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
# 执行更新
for item in to_update:
filtered = self._filter_writable_fields(item, allowed_fields, field_perms)
filtered = self._convert_data_types(filtered)
item_id = filtered.pop("id", None)
# 填充子表系统字段(更新)
filtered = self._fill_system_fields_for_update(filtered)
# 填充子表非虚拟关联字段的值
filtered = await self._fill_linked_field_values(db, filtered, table_type="sub")
if filtered and item_id:
sql, params = self.sql_builder.build_update(
table=table_name,
data=filtered,
pk_field="id",
pk_value=item_id,
schema=schema,
database=database
)
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
# 执行新增
for item in to_insert:
filtered = self._filter_writable_fields(
item, allowed_fields, field_perms, always_allow=writable_allow
)
filtered = self._convert_data_types(filtered)
if "id" in filtered:
del filtered["id"]
filtered["id"] = str(uuid.uuid4())
if foreign_key in filtered:
del filtered[foreign_key]
filtered[foreign_key] = main_pk
# 填充子表系统字段(新增)
filtered = self._fill_system_fields_for_create(filtered)
# 填充子表非虚拟关联字段的值
filtered = await self._fill_linked_field_values(db, filtered, table_type="sub")
if filtered and len(filtered) > 2:
sql, params = self.sql_builder.build_insert(
table=table_name,
data=filtered,
schema=schema,
database=database,
return_id=False
)
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
async def delete(self, db: AsyncSession, pk: Any) -> bool:
"""删除数据(含子表,事务)"""
# 验证数据存在(不存在时 get 会抛出 RecordNotFoundException
await self.get(db, pk)
main_database = self.form_meta.main_table_database or None
async with self._business_transaction(
database=self._table_exec_database(main_database)
):
for sub_table in self.sub_tables:
sub_db = sub_table.table_database or None
sql, params = self.sql_builder.build_delete_by_foreign_key(
table=sub_table.table_name,
fk_field=sub_table.foreign_key,
fk_value=pk,
schema=sub_table.table_schema or None,
database=sub_db,
)
await self._execute_command(
db,
sql,
params,
database=self._table_exec_database(sub_db),
)
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
sql, params = self.sql_builder.build_delete(
table=table,
pk_field="id",
pk_value=pk,
schema=schema,
database=database,
)
affected = await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
await db.commit()
logger.info(f"表单数据删除成功: form={self._form_code}, pk={pk}")
return affected > 0
async def batch_delete(self, db: AsyncSession, pks: List[Any]) -> int:
"""
批量删除(优化版:使用 IN 子句批量删除,单次事务提交)
Args:
db: 数据库会话
pks: 主键列表
Returns:
成功删除的数量
"""
if not pks:
return 0
async with self._business_transaction():
affected = await self._batch_delete_in_transaction(db, pks)
await db.commit()
logger.info(f"批量删除成功: form={self._form_code}, count={affected}")
return affected
async def _batch_delete_in_transaction(self, db: AsyncSession, pks: List[Any]) -> int:
for sub_table in self.sub_tables:
schema = await self._resolve_schema(db, sub_table.table_schema) if sub_table.table_schema else None
database = sub_table.table_database or None
# 构建批量删除 SQL: DELETE FROM table WHERE fk_field IN (:pk0, :pk1, ...)
full_table = self.sql_builder.build_table_name(sub_table.table_name, schema, database)
fk_field = self.sql_builder.quote_identifier(sub_table.foreign_key)
placeholders = ", ".join(f":pk{i}" for i in range(len(pks)))
sql = f"DELETE FROM {full_table} WHERE {fk_field} IN ({placeholders})"
params = {f"pk{i}": pk for i, pk in enumerate(pks)}
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
# 2. 批量删除主表数据(使用 IN 子句)
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
full_table = self.sql_builder.build_table_name(table, schema, database)
id_field = self.sql_builder.quote_identifier("id")
placeholders = ", ".join(f":pk{i}" for i in range(len(pks)))
sql = f"DELETE FROM {full_table} WHERE {id_field} IN ({placeholders})"
params = {f"pk{i}": pk for i, pk in enumerate(pks)}
return await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
# ============ 工具方法 ============
def _serialize_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
"""序列化行数据(处理特殊类型)"""
result = {}
for key, value in row.items():
if isinstance(value, datetime):
result[key] = value.strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(value, date):
result[key] = value.strftime("%Y-%m-%d")
elif isinstance(value, Decimal):
result[key] = float(value)
elif isinstance(value, uuid.UUID):
result[key] = str(value)
elif isinstance(value, bytes):
result[key] = value.decode("utf-8", errors="ignore")
elif isinstance(value, str):
result[key] = value.strip()
else:
result[key] = value
return result
# ============ 导入导出 ============
async def export_to_excel_streaming(
self,
db: AsyncSession,
selected_fields: List[str] = None,
include_sub_tables: bool = False,
batch_size: int = 1000,
data_scope: Dict[str, Any] = None,
filters: Dict[str, Any] = None,
sort_list: List[Dict[str, str]] = None,
search: str = None,
search_fields: List[str] = None,
on_progress: Any = None
) -> BytesIO:
"""
流式导出数据到 Excel(分批查询,避免内存溢出)
Args:
db: 数据库会话
selected_fields: 选中的字段列表
include_sub_tables: 是否导出子表
batch_size: 每批查询的数据量
data_scope: 数据权限过滤配置
filters: 过滤条件(与列表查询一致)
sort_list: 排序列表
search: 搜索关键词
search_fields: 搜索字段列表
on_progress: 进度回调函数 async fn(processed, total),可选
Returns:
Excel 文件的 BytesIO 对象
"""
wb = Workbook()
ws = wb.active
ws.title = "主表数据"
# 从列表配置中获取列定义
list_config = self._list_config
columns = list_config.get("columns", [])
# 如果指定了选择的字段,只导出这些字段
if selected_fields and columns:
columns = [col for col in columns if col.get("field") in selected_fields]
# 确保 selected_fields 中的字段都被包含
existing_fields = {col.get("field") for col in columns}
for field in selected_fields:
if field not in existing_fields:
columns.append({"field": field, "label": field.upper()})
if columns:
headers = [col.get("field") for col in columns]
column_labels = [col.get("label", col.get("field")) for col in columns]
display_fields = {
col.get("field"): col.get("displayField")
for col in columns
if col.get("displayField")
}
else:
# 如果没有列配置,先查询一条数据获取字段
first_result = await self.list(db=db, page=1, page_size=1, data_scope=data_scope)
if first_result["items"]:
headers = list(first_result["items"][0].keys())
else:
headers = []
column_labels = headers
display_fields = {}
# 从 relation_fields 补充缺少 displayField 的关联字段映射
relation_fields = self._get_relation_fields()
for field_name, config in relation_fields.items():
if field_name not in display_fields and config.get("display_field"):
display_fields[field_name] = config["display_field"]
# 先查询总数(用于进度计算)
total_count = 0
if on_progress:
count_result = await self.list(
db=db, page=1, page_size=1, data_scope=data_scope,
filters=filters, sort_list=sort_list,
search=search, search_fields=search_fields
)
total_count = min(count_result.get("total", 0), MAX_IMPORT_EXPORT_ROWS)
await on_progress(0, total_count, "querying")
# 定义样式
header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_alignment = Alignment(horizontal='center', vertical='center')
data_alignment = Alignment(horizontal='left', vertical='center')
thin_border = Border(
left=Side(style='thin', color='D0D0D0'),
right=Side(style='thin', color='D0D0D0'),
top=Side(style='thin', color='D0D0D0'),
bottom=Side(style='thin', color='D0D0D0')
)
# 写入表头
ws.append(column_labels)
for col_idx, cell in enumerate(ws[1], 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
column_letter = cell.column_letter
header_length = len(str(cell.value))
ws.column_dimensions[column_letter].width = max(12, header_length + 4)
# 子表 sheet 初始化(延迟到第一批有数据时创建表头)
sub_sheets: Dict[str, Any] = {}
sub_headers_map: Dict[str, List[str]] = {}
# 分批查询并写入数据(主表 + 子表同步处理)
page = 1
total_rows = 0
while True:
result = await self.list(
db=db, page=page, page_size=batch_size, data_scope=data_scope,
filters=filters, sort_list=sort_list,
search=search, search_fields=search_fields
)
items = result["items"]
if not items:
break
if page == 1 and total_count == 0:
total_count = min(result.get("total", 0), MAX_IMPORT_EXPORT_ROWS)
batch_ids = []
for item in items:
row = []
for header in headers:
display_field = display_fields.get(header)
if display_field and display_field in item:
value = item.get(display_field, "")
else:
value = item.get(header, "")
if isinstance(value, (list, dict)):
value = str(value)
row.append(value)
ws.append(row)
total_rows += 1
if include_sub_tables and item.get("id"):
batch_ids.append(item.get("id"))
# 当前批次的子表数据立即查询并写入
if include_sub_tables and self.sub_tables and batch_ids:
for sub_table in self.sub_tables:
sub_key = sub_table.alias or sub_table.table_name
sql, params = self.sql_builder.build_select(
table=sub_table.table_name,
schema=sub_table.table_schema or None,
database=sub_table.table_database or None,
where={sub_table.foreign_key: {"type": "in", "value": batch_ids}}
)
rows = await self._execute_query(db, sql, params)
if not rows:
continue
sub_items = [self._serialize_row(r) for r in rows]
if sub_key not in sub_sheets:
ws_sub = wb.create_sheet(title=sub_key[:31])
sub_hdrs = ["主表ID"] + [k for k in sub_items[0].keys()]
ws_sub.append(sub_hdrs)
for col_idx, cell in enumerate(ws_sub[1], 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws_sub.column_dimensions[cell.column_letter].width = max(12, len(str(cell.value)) + 4)
sub_sheets[sub_key] = ws_sub
sub_headers_map[sub_key] = sub_hdrs
else:
ws_sub = sub_sheets[sub_key]
sub_hdrs = sub_headers_map[sub_key]
for sub_item in sub_items:
row = [sub_item.get(sub_table.foreign_key, "")]
for h in sub_hdrs[1:]:
value = sub_item.get(h, "")
if isinstance(value, (list, dict)):
value = str(value)
row.append(value)
ws_sub.append(row)
if on_progress:
await on_progress(total_rows, total_count, "querying")
if len(items) < batch_size:
break
page += 1
if total_rows >= MAX_IMPORT_EXPORT_ROWS:
logger.warning(f"导出数据量达到上限 {MAX_IMPORT_EXPORT_ROWS} 条(服务器内存 {SERVER_MEMORY_GB}GB),停止导出")
break
if on_progress:
await on_progress(total_rows, total_count, "generating")
ws.freeze_panes = 'A2'
for ws_sub in sub_sheets.values():
ws_sub.freeze_panes = 'A2'
# 保存到内存
output = BytesIO()
wb.save(output)
output.seek(0)
logger.info(f"流式导出完成: 共 {total_rows} 条数据")
return output
async def export_to_excel(self, items: List[Dict[str, Any]], selected_fields: List[str] = None, sub_tables_data: Dict[str, List[Dict[str, Any]]] = None) -> BytesIO:
"""导出数据到 Excel(带样式,支持字段选择和子表导出)"""
wb = Workbook()
ws = wb.active
ws.title = "主表数据"
if not items:
output = BytesIO()
wb.save(output)
output.seek(0)
return output
# 从列表配置中获取列定义
list_config = self._list_config
columns = list_config.get("columns", [])
# 如果没有列配置,使用第一行数据的所有字段
if not columns:
headers = list(items[0].keys())
column_labels = headers
display_fields = {}
else:
# 如果指定了选择的字段,只导出这些字段
if selected_fields:
columns = [col for col in columns if col.get("field") in selected_fields]
# 确保 selected_fields 中的字段都被包含(即使不在 columns 配置中)
existing_fields = {col.get("field") for col in columns}
for field in selected_fields:
if field not in existing_fields:
# 添加缺失的字段(如 id
columns.append({"field": field, "label": field.upper()})
headers = [col.get("field") for col in columns]
column_labels = [col.get("label", col.get("field")) for col in columns]
# 构建字段到 displayField 的映射
display_fields = {
col.get("field"): col.get("displayField")
for col in columns
if col.get("displayField")
}
# 定义样式
# 表头样式:深蓝色背景,白色粗体文字
header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_alignment = Alignment(horizontal='center', vertical='center')
# 数据单元格样式:居中对齐
data_alignment = Alignment(horizontal='left', vertical='center')
# 边框样式
thin_border = Border(
left=Side(style='thin', color='D0D0D0'),
right=Side(style='thin', color='D0D0D0'),
top=Side(style='thin', color='D0D0D0'),
bottom=Side(style='thin', color='D0D0D0')
)
# 写入表头
ws.append(column_labels)
# 设置表头样式
for col_idx, cell in enumerate(ws[1], 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
# 设置列宽(根据表头长度自动调整)
column_letter = cell.column_letter
header_length = len(str(cell.value))
ws.column_dimensions[column_letter].width = max(12, header_length + 4)
# 写入数据
for item in items:
row = []
for header in headers:
# 优先使用 displayField 的值(如果配置了)
display_field = display_fields.get(header)
if display_field and display_field in item:
value = item.get(display_field, "")
else:
value = item.get(header, "")
# 处理特殊类型
if isinstance(value, (list, dict)):
value = str(value)
row.append(value)
ws.append(row)
# 设置数据行样式
for row_idx in range(2, ws.max_row + 1):
for col_idx in range(1, ws.max_column + 1):
cell = ws.cell(row=row_idx, column=col_idx)
cell.alignment = data_alignment
cell.border = thin_border
# 交替行背景色(浅灰色)
if row_idx % 2 == 0:
cell.fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
# 冻结首行(表头)
ws.freeze_panes = 'A2'
# 导出子表数据
if sub_tables_data:
for sub_table_name, sub_data_list in sub_tables_data.items():
if not sub_data_list:
continue
# 为每个子表创建新的工作表
ws_sub = wb.create_sheet(title=sub_table_name[:31]) # Excel 工作表名称最多 31 字符
# 子表表头(包含主表ID
sub_headers = ["主表ID"] + [k for k in sub_data_list[0].keys() if k != "_main_id"]
ws_sub.append(sub_headers)
# 设置子表表头样式
for col_idx, cell in enumerate(ws_sub[1], 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
column_letter = cell.column_letter
ws_sub.column_dimensions[column_letter].width = max(12, len(str(cell.value)) + 4)
# 写入子表数据
for sub_item in sub_data_list:
row = [sub_item.get("_main_id", "")]
for header in sub_headers[1:]:
value = sub_item.get(header, "")
# 处理特殊类型
if isinstance(value, (list, dict)):
value = str(value)
row.append(value)
ws_sub.append(row)
# 设置子表数据行样式
for row_idx in range(2, ws_sub.max_row + 1):
for col_idx in range(1, ws_sub.max_column + 1):
cell = ws_sub.cell(row=row_idx, column=col_idx)
cell.alignment = data_alignment
cell.border = thin_border
if row_idx % 2 == 0:
cell.fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
ws_sub.freeze_panes = 'A2'
# 保存到内存
output = BytesIO()
wb.save(output)
output.seek(0)
return output
async def get_import_template(self) -> BytesIO:
"""生成导入模板(带样式)"""
wb = Workbook()
ws = wb.active
ws.title = "导入模板"
# 从列表配置中获取列定义
list_config = self._list_config
columns = list_config.get("columns", [])
# 定义样式
header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_alignment = Alignment(horizontal='center', vertical='center')
thin_border = Border(
left=Side(style='thin', color='D0D0D0'),
right=Side(style='thin', color='D0D0D0'),
top=Side(style='thin', color='D0D0D0'),
bottom=Side(style='thin', color='D0D0D0')
)
if columns:
# 使用列配置生成表头(排除系统字段和关联字段的 displayField
headers = []
for col in columns:
field = col.get("field")
# 排除系统字段
if field not in ["id", "created_at", "updated_at", "sys_create_datetime", "sys_update_datetime"]:
# 排除 displayField(如 dept_name),只保留原始字段(如 dept_id)
display_field = col.get("displayField")
if not display_field or field != display_field:
headers.append(col.get("label", field))
ws.append(headers)
# 设置表头样式
for col_idx, cell in enumerate(ws[1], 1):
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
# 设置列宽
column_letter = cell.column_letter
header_length = len(str(cell.value))
ws.column_dimensions[column_letter].width = max(15, header_length + 4)
# 添加示例数据行(带浅黄色背景提示这是示例)
example_row = ["示例数据,请删除此行后填写实际数据"] + [""] * (len(headers) - 1)
ws.append(example_row)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=2, column=col_idx)
cell.fill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid')
cell.alignment = Alignment(horizontal='left', vertical='center')
cell.border = thin_border
cell.font = Font(name='微软雅黑', size=10, italic=True, color='808080')
else:
# 如果没有列配置,添加提示
ws.append(["请配置列表字段后重新下载模板"])
ws['A1'].font = Font(name='微软雅黑', size=12, bold=True, color='FF0000')
ws.column_dimensions['A'].width = 40
# 冻结首行
ws.freeze_panes = 'A2'
# 保存到内存
output = BytesIO()
wb.save(output)
output.seek(0)
return output
async def import_from_excel(
self,
db: AsyncSession,
file_content: bytes,
batch_size: int = 1000,
mode: str = "append",
validate_only: bool = False,
data_handling: str = "insert_only",
match_field: str = None,
on_progress=None
) -> tuple[int, int, List[Dict[str, Any]]]:
"""
从 Excel 批量导入数据
Args:
db: 数据库会话
file_content: Excel 文件内容
batch_size: 批量插入的批次大小,默认 1000
mode: 导入模式,"append"(追加)或 "overwrite"(覆盖,先清空表再导入)
validate_only: 是否仅验证数据,不执行实际导入
data_handling: 数据处理方式 insert_only / update_only / upsert
match_field: 更新模式下用于匹配的字段名
on_progress: 可选的异步回调 (processed, total, stage, success, fail)
Returns:
(成功数量, 失败数量, 错误详情列表)
"""
from utils.context import get_current_user_info_from_context
from sqlalchemy import text as sa_text
file_io = BytesIO(file_content)
del file_content
wb = load_workbook(file_io, read_only=True)
ws = wb.active
# read_only 模式下先读表头
row_iter = ws.iter_rows(values_only=True)
headers = list(next(row_iter, []))
# read_only 模式 ws.max_row 可能不准,用预估值(后续会动态修正)
total_excel_rows = (ws.max_row - 1) if ws.max_row and ws.max_row > 1 else 0
# 从列表配置中获取字段映射
list_config = self._list_config
columns = list_config.get("columns", [])
label_to_field = {col.get("label"): col.get("field") for col in columns if col.get("label") and col.get("field")}
# 获取允许的字段
allowed_fields = self._get_allowed_fields("main")
# 系统字段(需要排除)
system_fields = {"id", "created_at", "updated_at", "sys_create_datetime", "sys_update_datetime",
"sys_creator_id", "sys_modifier_id", "sys_dept_id", "is_deleted", "sort"}
success_count = 0
fail_count = 0
error_details = []
# 获取用户信息(用于填充系统字段)
user_info = get_current_user_info_from_context()
if on_progress:
await on_progress(0, total_excel_rows, "parsing", 0, 0)
# 根据预估总行数动态计算进度推送间隔(约推送 50~100 次,最少 50 行,最多 5000 行)
progress_interval = max(50, min(5000, total_excel_rows // 100)) if total_excel_rows > 0 else 100
# 流式逐行解析(read_only 模式下 iter_rows 是惰性的)
all_rows = []
parsed_count = 0
for row in row_iter:
row_num = parsed_count + 2
try:
# 跳过空行
if all(cell is None or (isinstance(cell, str) and cell.strip() == "") for cell in row):
parsed_count += 1
continue
# 构建数据字典
data = {}
for idx, value in enumerate(row):
if idx < len(headers) and headers[idx]:
field = label_to_field.get(headers[idx], headers[idx])
if field and field not in system_fields:
if field in allowed_fields:
data[field] = value
if data:
data = self._convert_data_types(data)
data = self._filter_fields(data, allowed_fields)
is_update_mode = mode == "append" and data_handling in ("update_only", "upsert")
if not is_update_mode:
data["id"] = str(uuid.uuid4())
data = self._fill_system_fields_for_create(data)
data = self._normalize_data_for_insert(data)
validation_error = self._validate_data_against_schema(row_num, data)
if validation_error:
fail_count += 1
error_details.append({"row": row_num, "error": validation_error})
parsed_count += 1
continue
all_rows.append((row_num, data))
if len(all_rows) >= MAX_IMPORT_EXPORT_ROWS:
logger.warning(f"导入数据量达到上限 {MAX_IMPORT_EXPORT_ROWS} 条(服务器内存 {SERVER_MEMORY_GB}GB),截断后续数据")
parsed_count += 1
break
except Exception as e:
logger.error(f"解析第 {row_num} 行数据失败: {e}")
fail_count += 1
error_details.append({"row": row_num, "error": str(e)})
parsed_count += 1
if on_progress and parsed_count % progress_interval == 0:
if parsed_count > total_excel_rows:
total_excel_rows = parsed_count
progress_interval = max(50, min(5000, total_excel_rows // 100))
await on_progress(parsed_count, total_excel_rows, "parsing", 0, fail_count)
await asyncio.sleep(0)
if parsed_count != total_excel_rows:
total_excel_rows = parsed_count
if on_progress:
await on_progress(parsed_count, total_excel_rows, "parsing", 0, fail_count)
wb.close()
file_io.close()
del wb, ws, row_iter, file_io
if not all_rows:
return success_count, fail_count, error_details
# 预先解析 schema(只解析一次)
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
# **按导入模式执行不同的验证规则**
if on_progress:
await on_progress(0, len(all_rows), "validating", 0, fail_count)
is_update_mode = mode == "append" and data_handling in ("update_only", "upsert")
unique_errors: Dict[int, str] = {}
existing_set: Set[str] = set()
if mode == "overwrite":
unique_errors = await self._batch_check_unique_fields_internal_only(all_rows)
elif data_handling == "insert_only":
unique_errors = await self._batch_check_unique_fields(db, all_rows)
elif data_handling in ("update_only", "upsert") and match_field:
match_label = self._get_field_labels([match_field]).get(match_field, match_field)
for row_num, data in all_rows:
val = data.get(match_field)
if val is None or val == '':
unique_errors[row_num] = f"匹配字段 '{match_label}' 不能为空"
match_val_to_rows: Dict[str, list] = {}
for row_num, data in all_rows:
val = data.get(match_field)
if val is not None and val != '':
match_val_to_rows.setdefault(str(val), []).append(row_num)
for val, rows in match_val_to_rows.items():
if len(rows) > 1:
for r in rows[1:]:
if r not in unique_errors:
unique_errors[r] = f"匹配字段 '{match_label}''{val}' 在导入数据中重复(第 {rows[0]} 行已存在)"
if on_progress:
await on_progress(len(all_rows) // 3, len(all_rows), "validating", 0, fail_count)
match_values = [str(data.get(match_field)) for _, data in all_rows
if data.get(match_field) not in (None, '')]
existing_set = await self._batch_query_existing(db, match_field, match_values)
if on_progress:
await on_progress(len(all_rows) * 2 // 3, len(all_rows), "validating", 0, fail_count)
if data_handling == "update_only":
for row_num, data in all_rows:
val = data.get(match_field)
if val is not None and val != '' and str(val) not in existing_set and row_num not in unique_errors:
unique_errors[row_num] = f"未找到匹配记录({match_label}={val}"
else:
new_rows = [(rn, d) for rn, d in all_rows
if d.get(match_field) is not None and d.get(match_field) != ''
and str(d.get(match_field)) not in existing_set
and rn not in unique_errors]
if new_rows:
insert_unique_errors = await self._batch_check_unique_fields(db, new_rows)
unique_errors.update(insert_unique_errors)
if on_progress:
await on_progress(len(all_rows), len(all_rows), "validating", 0, fail_count)
if unique_errors:
filtered_rows = []
for row_num, data in all_rows:
if row_num in unique_errors:
fail_count += 1
error_details.append({"row": row_num, "error": unique_errors[row_num]})
else:
filtered_rows.append((row_num, data))
all_rows = filtered_rows
if not all_rows:
logger.info(f"Excel 导入完成: 成功 {success_count} 条, 失败 {fail_count} 条(所有数据验证失败)")
return success_count, fail_count, error_details[:100]
# **仅验证模式**:返回验证结果,不执行实际导入
if validate_only:
success_count = len(all_rows)
# 计算各模式下的操作预估
validate_meta: Dict[str, Any] = {"_meta": True}
if mode == "overwrite":
validate_meta["will_insert"] = success_count
validate_meta["will_update"] = 0
validate_meta["action"] = "overwrite"
elif data_handling == "insert_only":
validate_meta["will_insert"] = success_count
validate_meta["will_update"] = 0
validate_meta["action"] = "insert_only"
elif data_handling == "update_only":
validate_meta["will_insert"] = 0
validate_meta["will_update"] = success_count
validate_meta["action"] = "update_only"
elif data_handling == "upsert" and match_field:
will_update = sum(
1 for _, d in all_rows
if d.get(match_field) is not None and d.get(match_field) != ''
and str(d.get(match_field)) in existing_set
)
will_insert = success_count - will_update
validate_meta["will_insert"] = will_insert
validate_meta["will_update"] = will_update
validate_meta["action"] = "upsert"
else:
validate_meta["will_insert"] = success_count
validate_meta["will_update"] = 0
validate_meta["action"] = data_handling
truncated_errors = error_details[:100]
truncated_errors.append(validate_meta)
logger.info(f"Excel 数据验证完成: 通过 {success_count} 条, 失败 {fail_count} 条, "
f"预计新增 {validate_meta.get('will_insert', 0)} 条, "
f"预计更新 {validate_meta.get('will_update', 0)}")
return success_count, fail_count, truncated_errors
main_exec_db = self._table_exec_database(database)
total_valid = len(all_rows)
imported_count = 0
if all_rows:
num_fields = len(all_rows[0][1])
if num_fields > 0:
max_safe_batch = 32000 // num_fields
batch_size = max(1, min(batch_size, max_safe_batch))
if on_progress:
await on_progress(0, total_valid, "importing", success_count, fail_count)
if is_update_mode and match_field:
async with self._business_transaction(database=main_exec_db):
success_count, fail_count, error_details = await self._import_with_update(
db,
table,
schema,
database,
all_rows,
match_field,
data_handling,
success_count,
fail_count,
error_details,
on_progress=on_progress,
)
if not self.db_adapter.is_external:
await db.commit()
return success_count, fail_count, error_details[:100]
async def _import_append_batch(batch_rows: List[tuple]) -> None:
nonlocal success_count, fail_count, imported_count
batch_data_list = [item[1] for item in batch_rows]
batch_row_nums = [item[0] for item in batch_rows]
sp_batch = f"sp_batch_{uuid.uuid4().hex[:8]}"
try:
await self._create_savepoint(db, sp_batch)
await self._batch_insert_raw(
db, table, schema, database, batch_data_list
)
await self._release_savepoint(db, sp_batch)
success_count += len(batch_data_list)
except Exception as batch_err:
logger.warning(
f"批量插入失败(第 {batch_row_nums[0]}-{batch_row_nums[-1]} 行),"
f"降级逐条插入: {batch_err}"
)
await self._rollback_to_savepoint(db, sp_batch)
for row_num, data in batch_rows:
sp_single = f"sp_{uuid.uuid4().hex[:8]}"
try:
await self._create_savepoint(db, sp_single)
sql, params = self.sql_builder.build_insert(
table=table,
data=data,
schema=schema,
database=database,
return_id=False,
)
await self._execute_command(
db,
sql,
params,
enrich_fk=False,
database=main_exec_db,
)
await self._release_savepoint(db, sp_single)
success_count += 1
except Exception as single_err:
await self._rollback_to_savepoint(db, sp_single)
fail_count += 1
error_details.append({
"row": row_num,
"error": format_error_message(single_err),
})
imported_count += len(batch_rows)
if self.db_adapter.is_external:
# 第三方库:每批独立 Handler 事务提交,避免整表导入失败时全部回滚
if mode == "overwrite":
async with self._business_transaction(database=main_exec_db):
await self._truncate_table(db, table, schema, database)
logger.info(f"覆盖模式:已清空表 {table}")
for batch_start in range(0, len(all_rows), batch_size):
batch = all_rows[batch_start:batch_start + batch_size]
async with self._business_transaction(database=main_exec_db):
await _import_append_batch(batch)
db.expire_all()
if on_progress:
await on_progress(
imported_count,
total_valid,
"importing",
success_count,
fail_count,
)
else:
async with self._business_transaction(database=main_exec_db):
if mode == "overwrite":
await self._truncate_table(db, table, schema, database)
logger.info(f"覆盖模式:已清空表 {table}")
for batch_start in range(0, len(all_rows), batch_size):
batch = all_rows[batch_start:batch_start + batch_size]
await _import_append_batch(batch)
await db.commit()
db.expire_all()
if on_progress:
await on_progress(
imported_count,
total_valid,
"importing",
success_count,
fail_count,
)
logger.info(f"Excel 导入完成: 成功 {success_count} 条, 失败 {fail_count}")
return success_count, fail_count, error_details[:100]
async def _import_with_update(
self,
db: AsyncSession,
table: str,
schema: Optional[str],
database: Optional[str],
all_rows: List[tuple],
match_field: str,
data_handling: str,
success_count: int,
fail_count: int,
error_details: List[Dict[str, Any]],
on_progress=None
) -> tuple[int, int, List[Dict[str, Any]]]:
"""
导入数据(更新/upsert 模式)
通过 match_field 匹配已有记录:
- update_only: 匹配到则更新,未匹配到则跳过
- upsert: 匹配到则更新,未匹配到则新增
"""
full_table = self.sql_builder.build_table_name(table, schema, database)
quoted_match = self.sql_builder.quote_identifier(match_field)
total_valid = len(all_rows)
# 1. 收集所有行的匹配字段值
match_values = []
for row_num, data in all_rows:
val = data.get(match_field)
if val is not None:
match_values.append(val)
# 2. 批量查询数据库中已存在的记录(match_field -> id 的映射)
existing_map = {}
if match_values:
for i in range(0, len(match_values), 500):
batch_vals = match_values[i:i + 500]
placeholders = ", ".join([f":mv_{j}" for j in range(len(batch_vals))])
quoted_id = self.sql_builder.quote_identifier("id")
query_sql = (
f"SELECT {quoted_id}, {quoted_match} FROM {full_table} "
f"WHERE {quoted_match} IN ({placeholders})"
)
params = {f"mv_{j}": v for j, v in enumerate(batch_vals)}
try:
result = await self._execute_query(
db,
query_sql,
params,
database=self._table_exec_database(database),
)
for row in result:
existing_map[row[match_field]] = row["id"]
except Exception as e:
logger.error(f"查询已有数据失败: {e}")
# 3. 分批处理每行数据
imported_count = 0
commit_batch_size = 500
main_exec_db = self._table_exec_database(database)
import_progress_interval = max(50, min(5000, total_valid // 100)) if total_valid > 0 else 100
for row_num, data in all_rows:
match_val = data.get(match_field)
existing_id = existing_map.get(match_val) if match_val is not None else None
sp = f"sp_{uuid.uuid4().hex[:8]}"
try:
await self._create_savepoint(db, sp)
if existing_id:
update_data = {k: v for k, v in data.items() if k not in ("id", "sys_create_datetime", "sys_creator_id", "sys_dept_id")}
update_data = self._fill_system_fields_for_update(update_data)
sql, params = self.sql_builder.build_update(
table=table, data=update_data, pk_field="id", pk_value=existing_id,
schema=schema, database=database
)
await self._execute_command(
db,
sql,
params,
enrich_fk=False,
database=main_exec_db,
)
await self._release_savepoint(db, sp)
success_count += 1
elif data_handling == "upsert":
data["id"] = str(uuid.uuid4())
data = self._fill_system_fields_for_create(data)
sql, params = self.sql_builder.build_insert(
table=table, data=data, schema=schema,
database=database, return_id=False
)
await self._execute_command(
db,
sql,
params,
enrich_fk=False,
database=main_exec_db,
)
await self._release_savepoint(db, sp)
success_count += 1
else:
await self._release_savepoint(db, sp)
fail_count += 1
error_details.append({"row": row_num, "error": f"未找到匹配记录({match_field}={match_val}"})
except Exception as e:
await self._rollback_to_savepoint(db, sp)
fail_count += 1
error_details.append({
"row": row_num,
"error": format_error_message(e),
})
imported_count += 1
if (
self.db_adapter.is_external
and imported_count % commit_batch_size == 0
):
# 第三方在父级 _business_transaction 内由调用方整段提交;此处仅刷新 ORM
db.expire_all()
elif imported_count % commit_batch_size == 0 and not self.db_adapter.is_external:
await db.commit()
db.expire_all()
if on_progress and imported_count % import_progress_interval == 0:
await on_progress(imported_count, total_valid, "importing", success_count, fail_count)
if not self.db_adapter.is_external:
await db.commit()
db.expire_all()
if on_progress:
await on_progress(imported_count, total_valid, "importing", success_count, fail_count)
logger.info(f"Excel 导入完成({data_handling}: 成功 {success_count} 条, 失败 {fail_count}")
return success_count, fail_count, error_details
async def _truncate_table(
self, db: AsyncSession, table: str, schema: Optional[str], database: Optional[str]
) -> None:
"""
清空表数据(用于覆盖模式)
Args:
db: 数据库会话
table: 表名
schema: Schema 名
database: 数据库名
"""
full_table = self.sql_builder.build_table_name(table, schema, database)
for sub_table in self.sub_tables:
sub_schema = (
await self._resolve_schema(db, sub_table.table_schema)
if sub_table.table_schema
else None
)
sub_database = sub_table.table_database or None
sub_full_table = self.sql_builder.build_table_name(
sub_table.table_name, sub_schema, sub_database
)
await self._execute_command(
db,
f"DELETE FROM {sub_full_table}",
database=self._table_exec_database(sub_database),
)
await self._execute_command(
db,
f"DELETE FROM {full_table}",
database=self._table_exec_database(database),
)
async def _batch_check_unique_fields_internal_only(
self, data_list: List[tuple[int, Dict[str, Any]]]
) -> Dict[int, str]:
"""
仅检查导入数据内部重复(用于覆盖模式,跳过数据库检查)
Args:
data_list: [(row_num, data), ...] 数据列表
Returns:
{row_num: error_message} 违反唯一性约束的行号和错误信息
"""
unique_fields = self._get_unique_check_fields()
if not unique_fields:
return {}
errors = {}
for field_name in unique_fields:
# 收集该字段的所有非空值及其行号
value_to_rows = {} # {value: [row_num1, row_num2, ...]}
for row_num, data in data_list:
value = data.get(field_name)
if value is None or value == '':
continue
value_str = str(value)
if value_str not in value_to_rows:
value_to_rows[value_str] = []
value_to_rows[value_str].append(row_num)
# 检查内部重复
for value, row_nums in value_to_rows.items():
if len(row_nums) > 1:
field_label = self._get_field_labels([field_name]).get(field_name, field_name)
for row_num in row_nums[1:]:
errors[row_num] = f"字段 '{field_label}''{value}' 在导入数据中重复(第 {row_nums[0]} 行已存在)"
return errors
async def _batch_check_unique_fields(
self, db: AsyncSession, data_list: List[tuple[int, Dict[str, Any]]]
) -> Dict[int, str]:
"""
批量检查唯一性约束(高性能版)
Args:
db: 数据库会话
data_list: [(row_num, data), ...] 数据列表
Returns:
{row_num: error_message} 违反唯一性约束的行号和错误信息
"""
unique_fields = self._get_unique_check_fields()
if not unique_fields:
return {}
errors = {}
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
table_name = self.sql_builder.build_table_name(table, schema, database)
for field_name in unique_fields:
# 收集该字段的所有非空值及其行号
value_to_rows = {} # {value: [row_num1, row_num2, ...]}
for row_num, data in data_list:
value = data.get(field_name)
if value is None or value == '':
continue
value_str = str(value)
if value_str not in value_to_rows:
value_to_rows[value_str] = []
value_to_rows[value_str].append(row_num)
if not value_to_rows:
continue
# 1. 检查导入数据内部重复
for value, row_nums in value_to_rows.items():
if len(row_nums) > 1:
# 内部重复:除了第一行,其他行都标记为错误
field_label = self._get_field_labels([field_name]).get(field_name, field_name)
for row_num in row_nums[1:]:
errors[row_num] = f"字段 '{field_label}''{value}' 在导入数据中重复(第 {row_nums[0]} 行已存在)"
# 2. 分批检查数据库中是否已存在(避免超出 32767 参数限制)
values = list(value_to_rows.keys())
if not values:
continue
quoted_field = self.sql_builder.quote_identifier(field_name)
has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted")
existing_values: set = set()
for batch_start in range(0, len(values), 500):
batch_vals = values[batch_start:batch_start + 500]
placeholders = ", ".join(f":v{i}" for i in range(len(batch_vals)))
sql = f"SELECT {quoted_field} FROM {table_name} WHERE {quoted_field} IN ({placeholders})"
params = {f"v{i}": v for i, v in enumerate(batch_vals)}
if has_is_deleted:
sql += f" AND {self.sql_builder.is_deleted_predicate()}"
result = await self._execute_query(db, sql, params)
existing_values.update(str(row[field_name]) for row in result)
# 标记数据库中已存在的值
field_label = self._get_field_labels([field_name]).get(field_name, field_name)
for value in existing_values:
for row_num in value_to_rows[value]:
if row_num not in errors: # 避免覆盖内部重复错误
errors[row_num] = f"字段 '{field_label}''{value}' 已存在于数据库中"
return errors
async def _batch_query_existing(
self, db: AsyncSession, field: str, values: List[str]
) -> Set[str]:
"""
批量查询数据库中某字段已存在的值集合
Args:
db: 数据库会话
field: 要查询的字段名
values: 要检查的值列表
Returns:
数据库中已存在的值集合(字符串形式)
"""
if not values:
return set()
table = self.form_meta.main_table
schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None
database = self.form_meta.main_table_database or None
table_name = self.sql_builder.build_table_name(table, schema, database)
quoted_field = self.sql_builder.quote_identifier(field)
existing: Set[str] = set()
unique_values = list(dict.fromkeys(values))
for i in range(0, len(unique_values), 500):
batch_vals = unique_values[i:i + 500]
placeholders = ", ".join(f":v{j}" for j in range(len(batch_vals)))
sql = f"SELECT {quoted_field} FROM {table_name} WHERE {quoted_field} IN ({placeholders})"
params = {f"v{j}": v for j, v in enumerate(batch_vals)}
if await self._check_column_exists(db, table, schema, database, "is_deleted"):
sql += f" AND {self.sql_builder.is_deleted_predicate()}"
result = await self._execute_query(db, sql, params)
for row in result:
existing.add(str(row[field] if field in row else list(row.values())[0]))
return existing
def _validate_data_against_schema(self, row_num: int, data: Dict[str, Any]) -> Optional[str]:
"""
根据数据库表结构验证数据
Args:
row_num: 行号
data: 数据字典
Returns:
错误信息,如果验证通过则返回 None
"""
form_config = self._form_config
table_configs = form_config.get('tableConfigs', [])
# 找到主表配置
main_table_config = None
for tc in table_configs:
if tc.get('type') == 'main':
main_table_config = tc
break
if not main_table_config:
return None
fields = main_table_config.get('fields', [])
field_map = {f.get('name'): f for f in fields if f.get('name')}
# 验证每个字段
for field_name, value in data.items():
# 跳过系统字段
if field_name in {'id', 'sys_create_datetime', 'sys_update_datetime',
'sys_creator_id', 'sys_modifier_id', 'sys_dept_id',
'is_deleted', 'sort'}:
continue
field_config = field_map.get(field_name)
if not field_config:
continue
# 1. NOT NULL 约束检查
nullable = field_config.get('nullable', True)
if not nullable and (value is None or (isinstance(value, str) and value.strip() == '')):
return f"字段 '{field_name}' 不能为空"
# 跳过 NULL 值的其他检查
if value is None:
continue
# 2. 字符串长度检查
field_type = field_config.get('type', '').lower()
max_length = field_config.get('maxLength')
if max_length and isinstance(value, str):
if 'varchar' in field_type or 'char' in field_type or 'text' in field_type:
if len(value) > max_length:
return f"字段 '{field_name}' 长度超限(最大 {max_length},实际 {len(value)}"
# 3. 数值类型检查
if 'int' in field_type or 'integer' in field_type:
if not isinstance(value, (int, float)):
try:
int(value)
except (ValueError, TypeError):
return f"字段 '{field_name}' 必须是整数"
elif 'decimal' in field_type or 'numeric' in field_type or 'float' in field_type or 'double' in field_type:
if not isinstance(value, (int, float)):
try:
float(value)
except (ValueError, TypeError):
return f"字段 '{field_name}' 必须是数值"
# 精度检查
precision = field_config.get('precision')
scale = field_config.get('scale')
if precision and isinstance(value, (int, float)):
value_str = str(value)
if '.' in value_str:
int_part, dec_part = value_str.split('.')
if len(int_part) + len(dec_part) > precision:
return f"字段 '{field_name}' 精度超限(最大 {precision}"
if scale and len(dec_part) > scale:
return f"字段 '{field_name}' 小数位数超限(最大 {scale}"
# 4. 日期时间类型检查
elif 'date' in field_type or 'time' in field_type:
if not isinstance(value, (date, datetime)):
return f"字段 '{field_name}' 必须是日期时间类型"
# 5. 布尔类型检查
elif 'bool' in field_type:
if not isinstance(value, bool):
return f"字段 '{field_name}' 必须是布尔类型"
return None
async def _batch_insert_raw(
self, db: AsyncSession, table: str, schema: Optional[str],
database: Optional[str], data_list: List[Dict[str, Any]]
) -> None:
"""
真正的批量 INSERT:一条 SQL 插入多行数据
Args:
db: 数据库会话
table: 表名
schema: Schema 名
database: 数据库名
data_list: 数据列表(已标准化)
"""
if not data_list:
return
columns = list(data_list[0].keys())
cols = ", ".join(self.sql_builder.quote_identifier(c) for c in columns)
full_table = self.sql_builder.build_table_name(table, schema, database)
# 构建多行 VALUES,使用命名参数
row_placeholders = []
params = {}
for row_idx, data in enumerate(data_list):
placeholders = []
for col in columns:
param_name = f"p{row_idx}_{col}"
placeholders.append(f":{param_name}")
params[param_name] = data.get(col)
row_placeholders.append(f"({', '.join(placeholders)})")
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
await self._execute_command(
db, sql, params, database=self._table_exec_database(database)
)
def _normalize_data_for_insert(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
标准化数据类型,确保所有值都是数据库可接受的类型
Args:
data: 原始数据字典
Returns:
标准化后的数据字典
"""
normalized = {}
for key, value in data.items():
if value is None:
normalized[key] = None
elif isinstance(value, datetime):
# datetime 对象保持不变,数据库驱动会正确处理
normalized[key] = value
elif isinstance(value, date):
# date 对象保持不变
normalized[key] = value
elif isinstance(value, (int, float, bool)):
# 基本类型保持不变
normalized[key] = value
elif isinstance(value, (list, dict)):
# 列表和字典转换为 JSON 字符串
normalized[key] = json.dumps(value, ensure_ascii=False)
elif isinstance(value, str):
# 字符串保持不变
normalized[key] = value
else:
# 其他类型转换为字符串
normalized[key] = str(value)
return normalized
# ============ 字段权限过滤 ============
async def _get_merged_field_permissions(
self,
db: AsyncSession,
role_ids: List[str] = None
) -> Dict[str, Dict]:
"""获取当前用户合并后的字段权限配置"""
from utils.context import get_current_user_info_from_context
from core.resource_scope.field_permission.service import ResourceFieldPermissionService
if not role_ids:
user_info = get_current_user_info_from_context()
logger.debug(f"[字段权限] 从上下文获取用户信息: {user_info}")
if not user_info or not user_info.get('role_ids'):
logger.warning("[字段权限] 未获取到用户角色信息,跳过字段权限过滤")
return {}
role_ids = user_info['role_ids']
resource_type = f"form:{self._form_code}"
logger.debug(f"[字段权限] 资源类型: {resource_type}, 角色IDs: {role_ids}")
configs = await ResourceFieldPermissionService.get_by_roles_and_resource(
db, role_ids, resource_type
)
logger.debug(f"[字段权限] 获取到的配置数量: {len(configs) if configs else 0}")
if not configs:
logger.debug("[字段权限] 未找到字段权限配置,跳过过滤")
return {}
merged_perms = await ResourceFieldPermissionService.merge_field_permissions(
configs, "most_permissive"
)
logger.debug(f"[字段权限] 合并后的权限: {merged_perms}")
return merged_perms or {}
async def apply_field_permissions(
self,
data: Any,
db: AsyncSession,
role_ids: List[str] = None
) -> Any:
"""
应用字段权限过滤
Args:
data: 数据(单个对象或列表)
db: 数据库会话
role_ids: 角色ID列表,如果不传则从上下文获取
Returns:
过滤后的数据
"""
merged_perms = await self._get_merged_field_permissions(db, role_ids)
if not merged_perms:
return data
if isinstance(data, list):
return [self._apply_field_permissions(item, merged_perms) for item in data]
return self._apply_field_permissions(data, merged_perms)
@staticmethod
def _get_field_permission_type(field_name: str, field_perms: Dict[str, Dict]) -> str:
"""获取字段权限类型,未配置时默认可写"""
if not field_perms:
return 'write'
perm = field_perms.get(field_name, {})
return perm.get('permission_type') or perm.get('permission', 'write')
def _is_field_writable(self, field_name: str, field_perms: Dict[str, Dict]) -> bool:
"""判断字段是否可写"""
return self._get_field_permission_type(field_name, field_perms) == 'write'
def _filter_writable_fields(
self,
data: Dict[str, Any],
allowed_fields: Set[str],
field_perms: Dict[str, Dict],
always_allow: Optional[Set[str]] = None,
) -> Dict[str, Any]:
"""过滤字段白名单,并剔除只读/隐藏/脱敏字段的写入"""
filtered = self._filter_fields(data, allowed_fields)
if not field_perms:
return filtered
allow = always_allow or set()
return {
key: value
for key, value in filtered.items()
if key in allow or self._is_field_writable(key, field_perms)
}
def _apply_field_permissions(self, item: Dict[str, Any], field_perms: Dict[str, Dict]) -> Dict[str, Any]:
"""
应用字段权限过滤(隐藏、脱敏)
Args:
item: 数据项(字典)
field_perms: 字段权限配置
Returns:
过滤后的字典
"""
if not isinstance(item, dict):
return item
# 收集需要隐藏或脱敏的字段及其关联的 _name 字段
hidden_fields = set()
masked_fields = {} # field_name -> mask_rule
for field_name, perm in field_perms.items():
permission_type = perm.get('permission_type') or perm.get('permission', 'write')
if permission_type == 'hidden':
hidden_fields.add(field_name)
# 同时隐藏关联的 _name 字段
# 支持多种命名模式:
# - field -> field_name (如 居住地 -> 居住地_name, post_id -> post_id_name)
# - field_id -> field_name (如 manger_id -> manger_name)
hidden_fields.add(f"{field_name}_name")
if field_name.endswith('_id'):
base_name = field_name[:-3] # 去掉 _id
hidden_fields.add(f"{base_name}_name")
elif permission_type == 'masked':
masked_fields[field_name] = perm.get('mask_rule')
# 同时脱敏关联的 _name 字段(使用默认脱敏规则)
masked_fields[f"{field_name}_name"] = 'default'
if field_name.endswith('_id'):
base_name = field_name[:-3]
masked_fields[f"{base_name}_name"] = 'name'
filtered = {}
for field_name, value in item.items():
# 递归处理内嵌子表数据
if field_name == "sub_tables" and isinstance(value, dict):
filtered["sub_tables"] = {
table_name: [
self._apply_field_permissions(row, field_perms)
if isinstance(row, dict) else row
for row in rows
] if isinstance(rows, list) else rows
for table_name, rows in value.items()
}
continue
# 检查是否需要隐藏
if field_name in hidden_fields:
continue
# 检查是否需要脱敏
if field_name in masked_fields:
filtered[field_name] = self._mask_value(value, masked_fields[field_name])
continue
# 检查原始字段权限配置
perm = field_perms.get(field_name, {})
permission_type = perm.get('permission_type') or perm.get('permission', 'write')
if permission_type == 'hidden':
continue
elif permission_type == 'masked':
filtered[field_name] = self._mask_value(value, perm.get('mask_rule'))
else:
filtered[field_name] = value
return filtered
def _mask_value(self, value: Any, mask_rule: Optional[str]) -> str:
"""
脱敏处理
Args:
value: 原始值
mask_rule: 脱敏规则
Returns:
脱敏后的值
"""
if not value:
return value
value_str = str(value)
if mask_rule == "phone":
# 手机号脱敏:138****5678
if len(value_str) == 11:
return f"{value_str[:3]}****{value_str[-4:]}"
elif mask_rule == "email":
# 邮箱脱敏:abc***@example.com
if "@" in value_str:
local, domain = value_str.split("@", 1)
if len(local) > 3:
return f"{local[:3]}***@{domain}"
return f"{local[0]}***@{domain}"
elif mask_rule == "id_card":
# 身份证脱敏:110***********1234
if len(value_str) >= 8:
return f"{value_str[:3]}***********{value_str[-4:]}"
elif mask_rule == "name":
# 姓名脱敏:张*
if len(value_str) > 1:
return f"{value_str[0]}*"
return "*"
# 默认脱敏:显示前后各2个字符
if len(value_str) > 4:
return f"{value_str[:2]}***{value_str[-2:]}"
return "***"