Restore AI workflow design nodes
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单数据管理模块
|
||||
"""
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""表单数据数据库执行适配层:平台库 vs 第三方连接"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from online_dev.form_data_manager.dynamic_sql_builder import DynamicSQLBuilder
|
||||
from online_dev.form_data_manager.exceptions import (
|
||||
FormDataConnectionError,
|
||||
FormDataException,
|
||||
QueryError,
|
||||
)
|
||||
from utils.sql_param_compile import compile_sql_with_named_params
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_DB_TYPE = "postgresql"
|
||||
PLATFORM_SCHEMA = "public"
|
||||
|
||||
|
||||
class FormDataWriteForbidden(FormDataException):
|
||||
"""系统连接不允许表单业务数据写入"""
|
||||
|
||||
error_code = "FORM_WRITE_FORBIDDEN"
|
||||
http_status = 403
|
||||
|
||||
def __init__(self, db_config: str = ""):
|
||||
super().__init__(
|
||||
"系统数据库连接不允许写入表单业务数据,请使用第三方数据库连接",
|
||||
context={"db_config": db_config},
|
||||
)
|
||||
|
||||
|
||||
class FormDataDbAdapter:
|
||||
"""表单 CRUD SQL 执行抽象"""
|
||||
|
||||
db_type: str
|
||||
db_config: str
|
||||
is_system: bool
|
||||
is_external: bool
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
"""写操作前校验(系统连接禁止写业务表)"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
"""请求级事务;平台库由 API Session 提交,第三方由 handler 事务域。"""
|
||||
yield
|
||||
|
||||
|
||||
class PlatformSessionAdapter(FormDataDbAdapter):
|
||||
"""平台 AsyncSession(db_config=default)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_db: AsyncSession,
|
||||
db_type: str,
|
||||
db_config: str = "default",
|
||||
default_database: str = "",
|
||||
):
|
||||
self._db = platform_db
|
||||
self.db_type = db_type
|
||||
self.db_config = db_config
|
||||
self.default_database = (default_database or "").strip()
|
||||
self.is_system = db_config == "default"
|
||||
self.is_external = False
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
return
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
result = await self._db.execute(text(sql), params or {})
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
result = await self._db.execute(text(sql), params or {})
|
||||
return result.rowcount
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
yield
|
||||
|
||||
|
||||
class ExternalConnectionAdapter(FormDataDbAdapter):
|
||||
"""第三方连接:经 ConnectionResolver + database_manager 执行"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_db: AsyncSession,
|
||||
db_config: str,
|
||||
db_type: str,
|
||||
is_system: bool,
|
||||
default_database: str = "",
|
||||
):
|
||||
self._platform_db = platform_db
|
||||
self.db_config = db_config
|
||||
self.db_type = db_type
|
||||
self.default_database = (default_database or "").strip()
|
||||
self.is_system = is_system
|
||||
self.is_external = True
|
||||
self._manager_service = None
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
if self.is_system:
|
||||
raise FormDataWriteForbidden(self.db_config)
|
||||
|
||||
async def _get_manager_service(self):
|
||||
if self._manager_service is None:
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
|
||||
self._manager_service = await AsyncDatabaseManagerService.create(
|
||||
self.db_config, self._platform_db
|
||||
)
|
||||
return self._manager_service
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
compiled = compile_sql_with_named_params(sql, params or {}, self.db_type)
|
||||
service = await self._get_manager_service()
|
||||
result_data = await service.execute_sql(
|
||||
compiled, is_query=True, database=database
|
||||
)
|
||||
if not result_data.get("success"):
|
||||
msg = result_data.get("message", "查询失败")
|
||||
logger.error(
|
||||
"External form query failed [%s] db_type=%s: %s | SQL: %s",
|
||||
self.db_config,
|
||||
self.db_type,
|
||||
msg,
|
||||
compiled[:500] if len(compiled) > 500 else compiled,
|
||||
)
|
||||
raise QueryError(detail=msg)
|
||||
return result_data.get("rows") or []
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
self.ensure_write_allowed()
|
||||
compiled = compile_sql_with_named_params(sql, params or {}, self.db_type)
|
||||
service = await self._get_manager_service()
|
||||
result_data = await service.execute_sql(
|
||||
compiled, is_query=False, database=database
|
||||
)
|
||||
if not result_data.get("success"):
|
||||
msg = result_data.get("message", "执行失败")
|
||||
logger.error(
|
||||
"External form command failed [%s] db_type=%s: %s | SQL: %s",
|
||||
self.db_config,
|
||||
self.db_type,
|
||||
msg,
|
||||
compiled[:500] if len(compiled) > 500 else compiled,
|
||||
)
|
||||
raise QueryError(detail=msg)
|
||||
affected = result_data.get("affected_rows")
|
||||
return int(affected) if affected is not None else 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
service = await self._get_manager_service()
|
||||
if not await service.begin_transaction(database=database):
|
||||
raise QueryError(detail="无法开启数据库事务")
|
||||
try:
|
||||
yield
|
||||
await service.commit_transaction()
|
||||
except Exception:
|
||||
await service.rollback_transaction()
|
||||
raise
|
||||
|
||||
async def create_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.create_savepoint(name)
|
||||
|
||||
async def release_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.release_savepoint(name)
|
||||
|
||||
async def rollback_to_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.rollback_to_savepoint(name)
|
||||
|
||||
|
||||
async def create_platform_adapter(platform_db: AsyncSession) -> PlatformSessionAdapter:
|
||||
"""平台库适配器(固定 default,用于 core_* 元数据查询)。"""
|
||||
from core.database_connection.resolver import ConnectionResolver
|
||||
|
||||
info = await ConnectionResolver.resolve("default", platform_db)
|
||||
return PlatformSessionAdapter(
|
||||
platform_db,
|
||||
PLATFORM_DB_TYPE,
|
||||
"default",
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
|
||||
async def create_form_data_adapter(
|
||||
db_config: str,
|
||||
platform_db: AsyncSession,
|
||||
) -> FormDataDbAdapter:
|
||||
"""按表单 db_config 创建业务库执行适配器"""
|
||||
from core.database_connection.resolver import ConnectionResolver
|
||||
|
||||
code = (db_config or "default").strip() or "default"
|
||||
try:
|
||||
info = await ConnectionResolver.resolve(code, platform_db)
|
||||
except ValueError as e:
|
||||
raise FormDataConnectionError(detail=str(e)) from e
|
||||
|
||||
if code == "default":
|
||||
return PlatformSessionAdapter(
|
||||
platform_db,
|
||||
info.db_type,
|
||||
code,
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
return ExternalConnectionAdapter(
|
||||
platform_db,
|
||||
code,
|
||||
info.db_type,
|
||||
info.is_system,
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
|
||||
def create_platform_sql_builder() -> DynamicSQLBuilder:
|
||||
return DynamicSQLBuilder(PLATFORM_DB_TYPE)
|
||||
|
||||
|
||||
def create_sql_builder_for_adapter(adapter: FormDataDbAdapter) -> DynamicSQLBuilder:
|
||||
return DynamicSQLBuilder(
|
||||
adapter.db_type,
|
||||
default_database=getattr(adapter, "default_database", "") or "",
|
||||
)
|
||||
|
||||
|
||||
async def resolve_form_sql_context(
|
||||
platform_db: AsyncSession,
|
||||
form_meta,
|
||||
*,
|
||||
adapter_cache: Optional[Dict[str, FormDataDbAdapter]] = None,
|
||||
builder_cache: Optional[Dict[str, DynamicSQLBuilder]] = None,
|
||||
) -> Tuple[FormDataDbAdapter, DynamicSQLBuilder]:
|
||||
"""按 FormMeta 解析业务 adapter 与 sql_builder(支持请求内缓存)。"""
|
||||
code = (form_meta.db_config or "default").strip() or "default"
|
||||
adapters = adapter_cache if adapter_cache is not None else {}
|
||||
builders = builder_cache if builder_cache is not None else {}
|
||||
|
||||
if code not in adapters:
|
||||
adapters[code] = await create_form_data_adapter(code, platform_db)
|
||||
if code not in builders:
|
||||
builders[code] = create_sql_builder_for_adapter(adapters[code])
|
||||
|
||||
return adapters[code], builders[code]
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据库异常转换器
|
||||
|
||||
将 SQLAlchemy / DBAPI 原生异常转换为 FormData 业务异常。
|
||||
同时提供 API 层的统一错误响应构建函数。
|
||||
|
||||
典型用法:
|
||||
from .db_error_handler import translate_db_error, build_error_response
|
||||
|
||||
# Service 层 — 在 _execute_command / _execute_query 中
|
||||
try:
|
||||
await db.execute(...)
|
||||
except Exception as e:
|
||||
raise translate_db_error(e) from e
|
||||
|
||||
# API 层 — 在路由函数中
|
||||
except FormDataException as e:
|
||||
raise build_error_response(e)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import (
|
||||
DBAPIError,
|
||||
IntegrityError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
DataError,
|
||||
InternalError,
|
||||
StatementError,
|
||||
TimeoutError as SATimeoutError,
|
||||
)
|
||||
|
||||
from online_dev.form_data_manager.exceptions import (
|
||||
FormDataException,
|
||||
FormDataValidationError,
|
||||
UniqueConstraintError,
|
||||
NotNullConstraintError,
|
||||
CheckConstraintError,
|
||||
ForeignKeyConstraintError,
|
||||
DataTypeMismatchError,
|
||||
DataTooLongError,
|
||||
TableNotFoundError,
|
||||
ColumnNotFoundError,
|
||||
DatabaseConnectionError,
|
||||
ConnectionPoolExhaustedError,
|
||||
ConnectionTimeoutError,
|
||||
DeadlockError,
|
||||
LockTimeoutError,
|
||||
QueryError,
|
||||
InternalDatabaseError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 正则:提取数据库错误中的关键信息
|
||||
# =====================================================================
|
||||
_RE_COLUMN = re.compile(r'column\s+["\']?(\w+)["\']?', re.IGNORECASE)
|
||||
_RE_TABLE = re.compile(r'(?:relation|table)\s+["\']?([\w."]+)["\']?', re.IGNORECASE)
|
||||
_RE_CONSTRAINT = re.compile(r'constraint\s+["\']?(\w+)["\']?', re.IGNORECASE)
|
||||
_RE_PG_DETAIL = re.compile(r'DETAIL:\s*(.*?)(?:\n|$)', re.IGNORECASE)
|
||||
_RE_REFERENCED_FROM_TABLE = re.compile(
|
||||
r'referenced from table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_FK_ON_TABLE = re.compile(
|
||||
r'foreign key constraint\s+"?(\w+)"?\s+on table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_NOT_PRESENT_IN_TABLE = re.compile(
|
||||
r'not present in table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_column(msg: str) -> str:
|
||||
m = _RE_COLUMN.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _extract_table(msg: str) -> str:
|
||||
m = _RE_TABLE.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _extract_constraint(msg: str) -> str:
|
||||
m = _RE_CONSTRAINT.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _normalize_table_name(table: str) -> str:
|
||||
if not table:
|
||||
return ""
|
||||
return table.strip().strip('"').split(".")[-1]
|
||||
|
||||
|
||||
def _extract_fk_referencing_table(msg: str) -> str:
|
||||
detail_match = _RE_PG_DETAIL.search(msg)
|
||||
detail = detail_match.group(1).strip() if detail_match else msg
|
||||
|
||||
referenced_match = _RE_REFERENCED_FROM_TABLE.search(detail)
|
||||
if referenced_match:
|
||||
return _normalize_table_name(referenced_match.group(1))
|
||||
|
||||
not_present_match = _RE_NOT_PRESENT_IN_TABLE.search(detail)
|
||||
if not_present_match:
|
||||
return _normalize_table_name(not_present_match.group(1))
|
||||
|
||||
fk_on_table_match = _RE_FK_ON_TABLE.search(msg)
|
||||
if fk_on_table_match:
|
||||
return _normalize_table_name(fk_on_table_match.group(2))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 核心转换函数
|
||||
# =====================================================================
|
||||
|
||||
def translate_db_error(exc: Exception) -> FormDataException:
|
||||
"""
|
||||
将数据库异常转换为业务异常。
|
||||
|
||||
优先使用 SQLAlchemy 异常类型进行判断,再辅以错误信息字符串匹配,
|
||||
确保对 PostgreSQL 和 MySQL 两种方言都有良好的覆盖。
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
orig_msg = str(exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. IntegrityError — 约束违反
|
||||
# SQLAlchemy 会将 asyncpg/MySQL 的约束错误包装为 IntegrityError,
|
||||
# 实际 DBAPI 异常在 exc.orig 中,类型判断必须用外层包装类。
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, IntegrityError):
|
||||
return _handle_integrity_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. DataError — 数据格式/长度问题
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, DataError):
|
||||
return _handle_data_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. OperationalError — 连接、死锁、超时等运行时问题
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, OperationalError):
|
||||
return _handle_operational_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. ProgrammingError — SQL 语法、表/列不存在
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, ProgrammingError):
|
||||
return _handle_programming_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. InternalError — 数据库内部错误(部分 MySQL 死锁也走这里)
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, InternalError):
|
||||
if "deadlock" in msg:
|
||||
return DeadlockError()
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. SQLAlchemy TimeoutError — 连接池超时
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, SATimeoutError):
|
||||
return ConnectionPoolExhaustedError()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. MissingGreenlet(异步上下文错误)
|
||||
# ------------------------------------------------------------------
|
||||
if "missinggreenlet" in msg or "MissingGreenlet" in orig_msg:
|
||||
return InternalDatabaseError(detail="async context error (MissingGreenlet)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. 其他 DBAPIError(含 PG 事务已中止)
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, DBAPIError):
|
||||
if _is_failed_transaction_error(msg):
|
||||
return _handle_failed_transaction_error(orig_msg)
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. 如果已经是业务异常,原样返回
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, FormDataException):
|
||||
return exc
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. 兜底 — 通过字符串匹配再尝试识别一轮
|
||||
# ------------------------------------------------------------------
|
||||
return _fallback_string_match(exc, msg, orig_msg)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 分类处理器
|
||||
# =====================================================================
|
||||
|
||||
def _handle_integrity_error(
|
||||
exc: IntegrityError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 IntegrityError 的各种子类型"""
|
||||
|
||||
# --- 唯一约束 ---
|
||||
if any(kw in msg for kw in ("unique", "duplicate key", "duplicate entry", "uniqueviolation")):
|
||||
fields = _extract_unique_fields(orig_msg)
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
if fields:
|
||||
labels = ", ".join(f"'{f}'" for f in fields)
|
||||
return UniqueConstraintError(
|
||||
f"以下字段的值已存在: {labels}",
|
||||
fields=fields,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return UniqueConstraintError(constraint_name=constraint)
|
||||
|
||||
# --- 非空约束 ---
|
||||
if any(kw in msg for kw in ("not-null", "notnullviolation", "null value in column", "cannot be null", "doesn't have a default")):
|
||||
col = _extract_column(orig_msg)
|
||||
return NotNullConstraintError(column=col)
|
||||
|
||||
# --- 外键约束 ---
|
||||
if any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"foreignkeyviolation",
|
||||
"restrictviolation",
|
||||
"a foreign key constraint fails",
|
||||
"is still referenced",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
# --- CHECK 约束 ---
|
||||
if any(kw in msg for kw in ("check constraint", "checkviolation", "check_violation")):
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
return CheckConstraintError(constraint_name=constraint)
|
||||
|
||||
# --- 无法识别的 IntegrityError ---
|
||||
return FormDataValidationError(
|
||||
"数据违反约束条件,请检查是否存在重复或无效数据",
|
||||
)
|
||||
|
||||
|
||||
def _handle_data_error(
|
||||
exc: DataError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 DataError"""
|
||||
|
||||
# 数据超长
|
||||
if any(kw in msg for kw in ("too long", "value too long", "data too long", "string data, right truncation")):
|
||||
col = _extract_column(orig_msg)
|
||||
return DataTooLongError(column=col)
|
||||
|
||||
# 数据类型不匹配
|
||||
if any(kw in msg for kw in (
|
||||
"invalid input syntax", "invalid text representation", "invalid input for query argument",
|
||||
"incorrect", "out of range", "numeric value", "expected str", "expected int",
|
||||
)):
|
||||
return DataTypeMismatchError(detail=orig_msg)
|
||||
|
||||
return DataTypeMismatchError(detail=orig_msg)
|
||||
|
||||
|
||||
def _handle_operational_error(
|
||||
exc: OperationalError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 OperationalError"""
|
||||
|
||||
# 死锁
|
||||
if any(kw in msg for kw in ("deadlock", "deadlock detected")):
|
||||
return DeadlockError()
|
||||
|
||||
# 锁等待超时
|
||||
if any(kw in msg for kw in ("lock wait timeout", "lock timeout", "could not obtain lock")):
|
||||
return LockTimeoutError()
|
||||
|
||||
# 连接相关
|
||||
if any(kw in msg for kw in (
|
||||
"connection refused", "connection reset",
|
||||
"connection timed out", "connection is closed",
|
||||
"server closed the connection", "broken pipe",
|
||||
"can't connect", "unable to connect",
|
||||
"lost connection", "gone away",
|
||||
)):
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
# 连接池超时
|
||||
if "queuepool" in msg and ("timeout" in msg or "limit" in msg):
|
||||
return ConnectionPoolExhaustedError()
|
||||
|
||||
# 查询超时 (statement_timeout / max_execution_time)
|
||||
if any(kw in msg for kw in ("statement timeout", "query timeout", "max_execution_time")):
|
||||
return ConnectionTimeoutError()
|
||||
|
||||
# 表不存在(MySQL 的 OperationalError 1146)
|
||||
if any(kw in msg for kw in ("doesn't exist", "does not exist", "no such table")):
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
|
||||
def _handle_programming_error(
|
||||
exc: ProgrammingError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 ProgrammingError"""
|
||||
|
||||
# 表不存在
|
||||
if any(kw in msg for kw in ("relation", "table")) and "does not exist" in msg:
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
# 列不存在
|
||||
if any(kw in msg for kw in ("column", "field", "unknown column")) and any(
|
||||
kw in msg for kw in ("does not exist", "not found", "unknown")
|
||||
):
|
||||
col = _extract_column(orig_msg)
|
||||
return ColumnNotFoundError(column=col)
|
||||
|
||||
# SQL 语法错误
|
||||
if any(kw in msg for kw in ("syntax error", "you have an error in your sql")):
|
||||
return QueryError(detail=orig_msg)
|
||||
|
||||
return QueryError(detail=orig_msg)
|
||||
|
||||
|
||||
def _fallback_string_match(
|
||||
exc: Exception, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""兜底的字符串匹配,尽可能识别常见场景"""
|
||||
|
||||
if "column" in msg and "does not exist" in msg:
|
||||
col = _extract_column(orig_msg)
|
||||
return ColumnNotFoundError(column=col)
|
||||
|
||||
if ("relation" in msg or "table" in msg) and "does not exist" in msg:
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
if "duplicate key" in msg or "unique" in msg:
|
||||
return UniqueConstraintError()
|
||||
|
||||
if "null value" in msg or "cannot be null" in msg:
|
||||
col = _extract_column(orig_msg)
|
||||
return NotNullConstraintError(column=col)
|
||||
|
||||
if any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
"a foreign key constraint fails",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
if _is_failed_transaction_error(msg):
|
||||
return _handle_failed_transaction_error(orig_msg)
|
||||
|
||||
if "connection" in msg:
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
|
||||
def _build_foreign_key_error(orig_msg: str) -> ForeignKeyConstraintError:
|
||||
detail_match = _RE_PG_DETAIL.search(orig_msg)
|
||||
detail = detail_match.group(1).strip() if detail_match else ""
|
||||
referenced_table = _extract_fk_referencing_table(orig_msg)
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
msg_lower = orig_msg.lower()
|
||||
|
||||
if any(kw in msg_lower for kw in ("delete", "referenced from table", "restrict setting")):
|
||||
if referenced_table:
|
||||
return ForeignKeyConstraintError(
|
||||
message=(
|
||||
f"无法删除,该数据已被「{referenced_table}」引用,"
|
||||
"请先删除或解除关联数据"
|
||||
),
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return ForeignKeyConstraintError(
|
||||
message="无法删除,该数据已被其他数据引用,请先删除或解除关联数据",
|
||||
detail=detail,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
if any(kw in msg_lower for kw in ("insert", "update")):
|
||||
if referenced_table:
|
||||
return ForeignKeyConstraintError(
|
||||
message=f"操作失败,引用的「{referenced_table}」数据不存在或无效",
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return ForeignKeyConstraintError(
|
||||
message="操作失败,引用的关联数据不存在或无效",
|
||||
detail=detail,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
return ForeignKeyConstraintError(
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
|
||||
def _is_failed_transaction_error(msg: str) -> bool:
|
||||
return any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"infailedsqltransaction",
|
||||
"current transaction is aborted",
|
||||
"commands ignored until end of transaction block",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_aborted_transaction_error(exc: Exception) -> bool:
|
||||
"""判断异常是否由 PostgreSQL 已中止事务引起。"""
|
||||
messages = [str(exc).lower()]
|
||||
orig = getattr(exc, "orig", None)
|
||||
if orig is not None:
|
||||
messages.append(str(orig).lower())
|
||||
return any(_is_failed_transaction_error(msg) for msg in messages)
|
||||
|
||||
|
||||
def _handle_failed_transaction_error(orig_msg: str) -> FormDataException:
|
||||
"""事务已失败后再次执行 SQL 的兜底处理,尽量保留原始约束错误语义。"""
|
||||
if any(
|
||||
kw in orig_msg.lower()
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
"a foreign key constraint fails",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
logger.error("Database operation attempted inside failed transaction: %s", orig_msg)
|
||||
return InternalDatabaseError(
|
||||
detail="transaction aborted before error could be translated"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 辅助函数
|
||||
# =====================================================================
|
||||
|
||||
def _extract_unique_fields(msg: str) -> list[str]:
|
||||
"""
|
||||
从唯一约束错误中尝试提取冲突的字段名。
|
||||
|
||||
PostgreSQL DETAIL: Key (email)=(xxx) already exists.
|
||||
MySQL: Duplicate entry 'xxx' for key 'uq_email'
|
||||
"""
|
||||
# PostgreSQL: Key (col1, col2)=(...) already exists
|
||||
pg_match = re.search(r'Key\s*\(([^)]+)\)', msg)
|
||||
if pg_match:
|
||||
return [f.strip().strip('"') for f in pg_match.group(1).split(",")]
|
||||
|
||||
# MySQL: for key 'index_name'
|
||||
mysql_match = re.search(r"for key\s+'(\w+)'", msg, re.IGNORECASE)
|
||||
if mysql_match:
|
||||
return [mysql_match.group(1)]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# API 层:统一错误响应
|
||||
# =====================================================================
|
||||
|
||||
def build_error_response(exc: FormDataException) -> HTTPException:
|
||||
"""
|
||||
将 FormDataException 转换为 FastAPI HTTPException。
|
||||
|
||||
响应 body 结构:
|
||||
{
|
||||
"detail": "用户友好的错误信息",
|
||||
"error_code": "UNIQUE_CONSTRAINT_VIOLATION",
|
||||
"context": { ... } // 可选的结构化上下文
|
||||
}
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=exc.http_status,
|
||||
detail={
|
||||
"message": str(exc),
|
||||
"error_code": exc.error_code,
|
||||
"context": exc.context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def format_error_message(exc: Exception, *, max_length: int = 300) -> str:
|
||||
"""将异常转为面向用户的简短错误信息(用于导入行级错误等场景)"""
|
||||
if isinstance(exc, FormDataException):
|
||||
return str(exc)[:max_length]
|
||||
return str(translate_db_error(exc))[:max_length]
|
||||
|
||||
|
||||
def _log_form_data_exception(exc: FormDataException) -> None:
|
||||
"""记录表单数据业务异常的完整信息(含 context 中的原始错误详情,不截断)"""
|
||||
log_fn = logger.error if exc.http_status >= 500 else logger.warning
|
||||
log_fn("表单数据业务异常: [%s] %s", exc.error_code, exc)
|
||||
|
||||
if not exc.context:
|
||||
return
|
||||
|
||||
if detail := exc.context.get("detail"):
|
||||
log_fn("原始错误详情:\n%s", detail)
|
||||
|
||||
other = {k: v for k, v in exc.context.items() if k != "detail" and v not in (None, "", [], {})}
|
||||
if other:
|
||||
log_fn("异常上下文: %s", other)
|
||||
|
||||
|
||||
def handle_db_error(e: Exception) -> HTTPException:
|
||||
"""
|
||||
API 层的统一入口:先转换为业务异常,再构建 HTTP 响应。
|
||||
|
||||
用法:
|
||||
except Exception as e:
|
||||
raise handle_db_error(e)
|
||||
"""
|
||||
if isinstance(e, FormDataException):
|
||||
_log_form_data_exception(e)
|
||||
return build_error_response(e)
|
||||
|
||||
logger.error("数据库操作错误: %s", e, exc_info=True)
|
||||
biz_exc = translate_db_error(e)
|
||||
_log_form_data_exception(biz_exc)
|
||||
return build_error_response(biz_exc)
|
||||
@@ -0,0 +1,979 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
动态 SQL 构建器(异步版本)
|
||||
支持 PostgreSQL、MySQL、SQL Server、Oracle 的 SQL 语法差异
|
||||
"""
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.timezone import APP_TIMEZONE
|
||||
from core.database_manager.sql_utils import quote_identifier as _quote_identifier
|
||||
from core.database_manager.sql_utils import quote_table as _quote_table
|
||||
|
||||
|
||||
def _normalize_db_type(db_type: str) -> str:
|
||||
normalized = (db_type or "postgresql").lower()
|
||||
if normalized in ("mssql",):
|
||||
return "sqlserver"
|
||||
if normalized in ("postgres", "psql"):
|
||||
return "postgresql"
|
||||
return normalized
|
||||
|
||||
|
||||
class DynamicSQLBuilder:
|
||||
"""动态 SQL 构建器 - 适配多种数据库"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_type: str,
|
||||
default_database: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
初始化 SQL 构建器
|
||||
|
||||
Args:
|
||||
db_type: postgresql | mysql | sqlserver | oracle
|
||||
default_database: 连接默认库(SQL Server 与之一致时不生成三段表名)
|
||||
"""
|
||||
self.db_type = _normalize_db_type(db_type)
|
||||
self.default_database = (default_database or "").strip()
|
||||
|
||||
def _uses_postgres_like(self) -> bool:
|
||||
return self.db_type == "postgresql"
|
||||
|
||||
def is_deleted_predicate(self) -> str:
|
||||
"""未删除行的 WHERE 条件片段(不含 AND)"""
|
||||
if self.db_type == "postgresql":
|
||||
return "is_deleted = false"
|
||||
return "is_deleted = 0"
|
||||
|
||||
def is_deleted_and_clause(self) -> str:
|
||||
"""未删除行的 AND 条件片段"""
|
||||
return f" AND {self.is_deleted_predicate()}"
|
||||
|
||||
def _like_operator(self, case_sensitive: bool) -> str:
|
||||
if self.db_type == "mysql":
|
||||
return "LIKE BINARY" if case_sensitive else "LIKE"
|
||||
if self._uses_postgres_like():
|
||||
return "LIKE" if case_sensitive else "ILIKE"
|
||||
return "LIKE"
|
||||
|
||||
# ============ 标识符引用 ============
|
||||
|
||||
def quote_identifier(self, name: str) -> str:
|
||||
"""引用标识符(表名、列名等)"""
|
||||
return _quote_identifier(name, self.db_type)
|
||||
|
||||
def _split_table_identifiers(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
"""
|
||||
解析表名中的限定符,避免 SQL Server 将 schema.table 当成单段对象名。
|
||||
|
||||
- MySQL: database.table(表名含点且无 database 时拆分)
|
||||
- PG / SQL Server / Oracle: schema.table
|
||||
"""
|
||||
table = (table or "").strip()
|
||||
schema = (schema or "").strip() or None
|
||||
database = (database or "").strip() or None
|
||||
if not table:
|
||||
return table, schema, database
|
||||
|
||||
if self.db_type == "mysql":
|
||||
if "." in table and not database:
|
||||
db_part, tbl_part = table.split(".", 1)
|
||||
if db_part.strip() and tbl_part.strip():
|
||||
return tbl_part.strip(), schema, db_part.strip()
|
||||
return table, schema, database
|
||||
|
||||
if "." in table:
|
||||
if not schema:
|
||||
sch, tbl = table.split(".", 1)
|
||||
if sch.strip() and tbl.strip():
|
||||
return tbl.strip(), sch.strip(), database
|
||||
else:
|
||||
prefix = f"{schema}."
|
||||
if table.lower().startswith(prefix.lower()):
|
||||
return table[len(prefix) :].strip(), schema, database
|
||||
if table.count(".") >= 1:
|
||||
return table.rsplit(".", 1)[-1].strip(), schema, database
|
||||
|
||||
return table, schema, database
|
||||
|
||||
def build_table_name(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建完整的表名
|
||||
|
||||
PostgreSQL / SQL Server / Oracle: schema.table
|
||||
MySQL: database.table
|
||||
SQL Server 可选: database.schema.table(main_table_database)
|
||||
"""
|
||||
table, schema, database = self._split_table_identifiers(table, schema, database)
|
||||
|
||||
if self.db_type == "mysql":
|
||||
if database:
|
||||
return (
|
||||
f"{_quote_identifier(database, self.db_type)}"
|
||||
f".{_quote_identifier(table, self.db_type)}"
|
||||
)
|
||||
return _quote_identifier(table, self.db_type)
|
||||
|
||||
effective_schema = schema
|
||||
if self.db_type == "sqlserver" and not effective_schema:
|
||||
effective_schema = "dbo"
|
||||
|
||||
table_ref = (
|
||||
_quote_table(effective_schema, table, self.db_type)
|
||||
if effective_schema
|
||||
else _quote_identifier(table, self.db_type)
|
||||
)
|
||||
if self.db_type == "sqlserver" and database:
|
||||
conn_db = self.default_database.lower()
|
||||
if conn_db and database.lower() == conn_db:
|
||||
return table_ref
|
||||
return f"{_quote_identifier(database, self.db_type)}.{table_ref}"
|
||||
return table_ref
|
||||
|
||||
def _default_paging_order_by(self) -> str:
|
||||
"""SQL Server / Oracle 使用 OFFSET/FETCH 时必须带 ORDER BY。"""
|
||||
if self.db_type == "sqlserver":
|
||||
return "(SELECT NULL)"
|
||||
if self.db_type == "oracle":
|
||||
return "1"
|
||||
return ""
|
||||
|
||||
def _append_order_by_for_paging(self, sql: str, order_by: Optional[str]) -> str:
|
||||
if order_by:
|
||||
return f"{sql} ORDER BY {order_by}"
|
||||
if self.db_type in ("sqlserver", "oracle"):
|
||||
placeholder = self._default_paging_order_by()
|
||||
if placeholder:
|
||||
return f"{sql} ORDER BY {placeholder}"
|
||||
return sql
|
||||
|
||||
def _append_limit_offset(
|
||||
self, sql: str, limit: Optional[int], offset: Optional[int]
|
||||
) -> str:
|
||||
"""追加分页子句"""
|
||||
if limit is None:
|
||||
return sql
|
||||
off = offset or 0
|
||||
if self.db_type == "sqlserver":
|
||||
return f"{sql} OFFSET {off} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
if self.db_type == "oracle":
|
||||
if off:
|
||||
return f"{sql} OFFSET {off} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
return f"{sql} FETCH FIRST {limit} ROWS ONLY"
|
||||
sql += f" LIMIT {limit}"
|
||||
if off:
|
||||
sql += f" OFFSET {off}"
|
||||
return sql
|
||||
|
||||
# ============ 参数占位符 ============
|
||||
|
||||
def get_placeholder(self, index: int = 0) -> str:
|
||||
"""
|
||||
获取参数占位符
|
||||
|
||||
PostgreSQL: $1, $2, ...
|
||||
MySQL: %s
|
||||
"""
|
||||
if self.db_type == "postgresql":
|
||||
return f"${index + 1}"
|
||||
return "%s"
|
||||
|
||||
def get_placeholders(self, count: int, start_index: int = 0) -> List[str]:
|
||||
"""获取多个占位符"""
|
||||
return [self.get_placeholder(start_index + i) for i in range(count)]
|
||||
|
||||
# ============ 数据类型转换 ============
|
||||
|
||||
@staticmethod
|
||||
def _convert_value(value: Any) -> Any:
|
||||
"""
|
||||
转换数据值为适合数据库的类型
|
||||
|
||||
注意:日期时间的转换应该由 service.py 的 _convert_data_types 方法
|
||||
根据字段类型来处理,这里只处理基本的数据结构转换
|
||||
|
||||
Args:
|
||||
value: 原始值
|
||||
|
||||
Returns:
|
||||
转换后的值
|
||||
"""
|
||||
# 处理列表和字典类型
|
||||
if isinstance(value, list):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
return value
|
||||
|
||||
def _build_like_expr(self, quoted_field: str, case_sensitive: bool) -> str:
|
||||
"""根据数据库类型和大小写敏感标志构建 LIKE 表达式
|
||||
|
||||
Args:
|
||||
quoted_field: 已引用的字段名
|
||||
case_sensitive: True=大小写敏感, False=大小写不敏感
|
||||
|
||||
Returns:
|
||||
如: "CAST(name AS TEXT) LIKE :p" 或 "CAST(name AS TEXT) ILIKE :p"
|
||||
"""
|
||||
if self._uses_postgres_like():
|
||||
if case_sensitive:
|
||||
return f"CAST({quoted_field} AS TEXT) LIKE :{{}}"
|
||||
return f"CAST({quoted_field} AS TEXT) ILIKE :{{}}"
|
||||
if case_sensitive:
|
||||
return f"CAST({quoted_field} AS VARCHAR(4000)) LIKE :{{}}"
|
||||
return f"LOWER(CAST({quoted_field} AS VARCHAR(4000))) LIKE LOWER(:{{}})"
|
||||
|
||||
# ============ SELECT 构建 ============
|
||||
|
||||
def build_select(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str] = None,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None,
|
||||
order_by: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 SELECT 语句,返回命名参数"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 列
|
||||
if columns:
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
else:
|
||||
cols = "*"
|
||||
|
||||
sql = f"SELECT {cols} FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
# WHERE
|
||||
if where:
|
||||
where_clause, where_params = self._build_where_named(where)
|
||||
if where_clause:
|
||||
sql += f" WHERE {where_clause}"
|
||||
params.update(where_params)
|
||||
|
||||
sql = self._append_order_by_for_paging(sql, order_by)
|
||||
sql = self._append_limit_offset(sql, limit, offset)
|
||||
|
||||
return sql, params
|
||||
|
||||
def build_count(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 COUNT 查询,返回命名参数"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"SELECT COUNT(*) as total FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
if where:
|
||||
where_clause, where_params = self._build_where_named(where)
|
||||
if where_clause:
|
||||
sql += f" WHERE {where_clause}"
|
||||
params.update(where_params)
|
||||
|
||||
return sql, params
|
||||
|
||||
def build_cursor_select(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str] = None,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None,
|
||||
order_fields: Optional[List[Tuple[str, str]]] = None,
|
||||
cursor_values: Optional[Dict[str, Any]] = None,
|
||||
direction: str = "next",
|
||||
limit: int = 20
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建游标分页 SELECT 语句
|
||||
|
||||
Args:
|
||||
order_fields: 排序字段列表 [(field, "ASC"|"DESC"), ...],最后一个必须是唯一键(id)
|
||||
cursor_values: 游标值字典 {field: value, ...},键与 order_fields 的 field 对应
|
||||
direction: 翻页方向 "next"(下一页) 或 "prev"(上一页)
|
||||
limit: 每页条数(内部会 +1 来判断 has_more)
|
||||
"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
if columns:
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
else:
|
||||
cols = "*"
|
||||
|
||||
sql = f"SELECT {cols} FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
where_clauses = []
|
||||
|
||||
# 先构建业务 WHERE 条件
|
||||
if where:
|
||||
biz_clause, biz_params = self._build_where_named(where)
|
||||
if biz_clause:
|
||||
where_clauses.append(biz_clause)
|
||||
params.update(biz_params)
|
||||
|
||||
# 构建游标 WHERE 条件(行值比较)
|
||||
if cursor_values and order_fields:
|
||||
cursor_clause, cursor_params = self._build_cursor_where(
|
||||
order_fields, cursor_values, direction
|
||||
)
|
||||
if cursor_clause:
|
||||
where_clauses.append(cursor_clause)
|
||||
params.update(cursor_params)
|
||||
|
||||
if where_clauses:
|
||||
sql += f" WHERE {' AND '.join(where_clauses)}"
|
||||
|
||||
# 构建 ORDER BY(prev 方向需要反转排序)
|
||||
order_by = None
|
||||
if order_fields:
|
||||
order_parts = []
|
||||
for field, raw_dir in order_fields:
|
||||
if direction == "prev":
|
||||
actual_dir = "ASC" if raw_dir.upper() == "DESC" else "DESC"
|
||||
else:
|
||||
actual_dir = raw_dir.upper()
|
||||
order_parts.append(f"{self.quote_identifier(field)} {actual_dir}")
|
||||
order_by = ", ".join(order_parts)
|
||||
|
||||
sql = self._append_order_by_for_paging(sql, order_by)
|
||||
sql = self._append_limit_offset(sql, limit + 1, 0)
|
||||
|
||||
return sql, params
|
||||
|
||||
def _cursor_comparison_op(
|
||||
self,
|
||||
fields_with_values: List[Tuple[str, str]],
|
||||
direction: str,
|
||||
) -> str:
|
||||
"""游标翻页比较符(与首列排序方向、翻页方向一致)。"""
|
||||
first_dir = fields_with_values[0][1].upper()
|
||||
if direction == "prev":
|
||||
return ">" if first_dir == "DESC" else "<"
|
||||
return "<" if first_dir == "DESC" else ">"
|
||||
|
||||
def _build_cursor_where_lexographic(
|
||||
self,
|
||||
fields_with_values: List[Tuple[str, str]],
|
||||
cursor_values: Dict[str, Any],
|
||||
direction: str,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""SQL Server / Oracle:等值前缀 + OR 链,避免行值比较语法不兼容。"""
|
||||
op = self._cursor_comparison_op(fields_with_values, direction)
|
||||
params: Dict[str, Any] = {}
|
||||
or_parts: List[str] = []
|
||||
|
||||
for i, (field, _) in enumerate(fields_with_values):
|
||||
conjuncts: List[str] = []
|
||||
for j in range(i):
|
||||
prev_field = fields_with_values[j][0]
|
||||
eq_name = f"cursor_eq_{i}_{j}"
|
||||
conjuncts.append(
|
||||
f"{self.quote_identifier(prev_field)} = :{eq_name}"
|
||||
)
|
||||
params[eq_name] = cursor_values[prev_field]
|
||||
cmp_name = f"cursor_cmp_{i}"
|
||||
conjuncts.append(
|
||||
f"{self.quote_identifier(field)} {op} :{cmp_name}"
|
||||
)
|
||||
params[cmp_name] = cursor_values[field]
|
||||
or_parts.append(f"({' AND '.join(conjuncts)})")
|
||||
|
||||
return f"({' OR '.join(or_parts)})", params
|
||||
|
||||
def _build_cursor_where(
|
||||
self,
|
||||
order_fields: List[Tuple[str, str]],
|
||||
cursor_values: Dict[str, Any],
|
||||
direction: str
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建游标 WHERE 条件。
|
||||
|
||||
PostgreSQL / MySQL:行值比较 (a, b) > (:a, :b)
|
||||
SQL Server / Oracle:等值前缀 + OR 链
|
||||
"""
|
||||
fields_with_values = [
|
||||
(f, d) for f, d in order_fields if f in cursor_values
|
||||
]
|
||||
if not fields_with_values:
|
||||
return "", {}
|
||||
|
||||
if self.db_type in ("sqlserver", "oracle"):
|
||||
return self._build_cursor_where_lexographic(
|
||||
fields_with_values, cursor_values, direction
|
||||
)
|
||||
|
||||
params = {}
|
||||
quoted_fields = []
|
||||
param_placeholders = []
|
||||
|
||||
for i, (field, _) in enumerate(fields_with_values):
|
||||
quoted_fields.append(self.quote_identifier(field))
|
||||
param_name = f"cursor_{i}"
|
||||
param_placeholders.append(f":{param_name}")
|
||||
params[param_name] = cursor_values[field]
|
||||
|
||||
op = self._cursor_comparison_op(fields_with_values, direction)
|
||||
lhs = f"({', '.join(quoted_fields)})"
|
||||
rhs = f"({', '.join(param_placeholders)})"
|
||||
clause = f"{lhs} {op} {rhs}"
|
||||
|
||||
return clause, params
|
||||
|
||||
# ============ INSERT 构建 ============
|
||||
|
||||
def build_insert(
|
||||
self,
|
||||
table: str,
|
||||
data: Dict[str, Any],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
return_id: bool = True
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 INSERT 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 清理字段名中的空格,并转换数据类型
|
||||
cleaned_data = {}
|
||||
|
||||
for key, value in data.items():
|
||||
cleaned_key = key.strip()
|
||||
# 转换日期时间字符串为 Python 对象
|
||||
converted_value = self._convert_value(value)
|
||||
cleaned_data[cleaned_key] = converted_value
|
||||
|
||||
columns = list(cleaned_data.keys())
|
||||
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
# 使用命名参数 :param_name(使用清理后的字段名)
|
||||
placeholders = ", ".join(f":{col}" for col in columns)
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES ({placeholders})"
|
||||
|
||||
# 返回自增 ID
|
||||
if return_id and self.db_type == "postgresql":
|
||||
sql += " RETURNING id"
|
||||
|
||||
return sql, cleaned_data
|
||||
|
||||
def build_batch_insert(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, List[Any]]:
|
||||
"""构建批量 INSERT 语句"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
|
||||
# 构建多行 VALUES
|
||||
row_placeholders = []
|
||||
params = []
|
||||
param_index = 0
|
||||
|
||||
for row in rows:
|
||||
placeholders = ", ".join(self.get_placeholders(len(row), param_index))
|
||||
row_placeholders.append(f"({placeholders})")
|
||||
params.extend(row)
|
||||
param_index += len(row)
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
|
||||
def build_batch_insert_named(
|
||||
self,
|
||||
table: str,
|
||||
data_list: List[Dict[str, Any]],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Any]:
|
||||
"""
|
||||
构建批量 INSERT 语句
|
||||
|
||||
Args:
|
||||
table: 表名
|
||||
data_list: 数据列表,每个元素是一个字典
|
||||
schema: Schema 名
|
||||
database: 数据库名
|
||||
|
||||
Returns:
|
||||
PostgreSQL: (SQL 语句, 位置参数列表)
|
||||
MySQL: (SQL 语句, 命名参数字典)
|
||||
"""
|
||||
if not data_list:
|
||||
raise ValueError("data_list 不能为空")
|
||||
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 获取所有列名(使用第一条数据的键)
|
||||
columns = list(data_list[0].keys())
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
|
||||
if self.db_type == "postgresql":
|
||||
# PostgreSQL 使用位置参数 $1, $2, ...
|
||||
row_placeholders = []
|
||||
params = []
|
||||
param_index = 0
|
||||
|
||||
for data in data_list:
|
||||
placeholders = []
|
||||
for col in columns:
|
||||
placeholders.append(f"${param_index + 1}")
|
||||
# 转换数据类型并添加到参数列表
|
||||
params.append(self._convert_value(data.get(col)))
|
||||
param_index += 1
|
||||
row_placeholders.append(f"({', '.join(placeholders)})")
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
else:
|
||||
# MySQL 使用命名参数
|
||||
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] = self._convert_value(data.get(col))
|
||||
row_placeholders.append(f"({', '.join(placeholders)})")
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
|
||||
# ============ UPDATE 构建 ============
|
||||
|
||||
def build_update(
|
||||
self,
|
||||
table: str,
|
||||
data: Dict[str, Any],
|
||||
pk_field: str,
|
||||
pk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 UPDATE 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
set_clauses = []
|
||||
params = {}
|
||||
param_counter = 0
|
||||
|
||||
# 清理字段名中的空格,并转换数据类型
|
||||
for col, val in data.items():
|
||||
cleaned_col = col.strip()
|
||||
# 转换日期时间字符串为 Python 对象
|
||||
converted_val = self._convert_value(val)
|
||||
param_name = f"param_{param_counter}"
|
||||
set_clauses.append(f"{self.quote_identifier(cleaned_col)} = :{param_name}")
|
||||
params[param_name] = converted_val
|
||||
param_counter += 1
|
||||
|
||||
# 清理主键字段名
|
||||
cleaned_pk_field = pk_field.strip()
|
||||
pk_param_name = f"param_{param_counter}"
|
||||
sql = f"UPDATE {full_table} SET {', '.join(set_clauses)} WHERE {self.quote_identifier(cleaned_pk_field)} = :{pk_param_name}"
|
||||
params[pk_param_name] = pk_value
|
||||
|
||||
return sql, params
|
||||
|
||||
# ============ DELETE 构建 ============
|
||||
|
||||
def build_delete(
|
||||
self,
|
||||
table: str,
|
||||
pk_field: str,
|
||||
pk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 DELETE 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"DELETE FROM {full_table} WHERE {self.quote_identifier(pk_field)} = :pk_value"
|
||||
return sql, {"pk_value": pk_value}
|
||||
|
||||
def build_delete_by_foreign_key(
|
||||
self,
|
||||
table: str,
|
||||
fk_field: str,
|
||||
fk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""根据外键删除,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"DELETE FROM {full_table} WHERE {self.quote_identifier(fk_field)} = :fk_value"
|
||||
return sql, {"fk_value": fk_value}
|
||||
|
||||
# ============ WHERE 条件构建 ============
|
||||
|
||||
def _build_where_named(self, conditions: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 WHERE 子句(使用命名参数)"""
|
||||
clauses = []
|
||||
params = {}
|
||||
param_counter = 0
|
||||
|
||||
for field, condition in conditions.items():
|
||||
if condition is None:
|
||||
continue
|
||||
|
||||
# 处理多字段搜索(OR 关系)
|
||||
if field == '__search__' and isinstance(condition, dict):
|
||||
keyword = condition.get('keyword')
|
||||
search_fields = condition.get('fields', [])
|
||||
if keyword and search_fields:
|
||||
search_clauses = []
|
||||
for search_field in search_fields:
|
||||
quoted_search_field = self.quote_identifier(search_field)
|
||||
param_name = f"search_param_{param_counter}"
|
||||
# 使用 ILIKE(PostgreSQL)或 LIKE(其他数据库)进行不区分大小写的模糊搜索
|
||||
if self._uses_postgres_like():
|
||||
search_clauses.append(
|
||||
f"CAST({quoted_search_field} AS TEXT) ILIKE :{param_name}"
|
||||
)
|
||||
else:
|
||||
search_clauses.append(
|
||||
f"LOWER(CAST({quoted_search_field} AS VARCHAR(4000))) "
|
||||
f"LIKE LOWER(:{param_name})"
|
||||
)
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
if search_clauses:
|
||||
clauses.append(f"({' OR '.join(search_clauses)})")
|
||||
continue
|
||||
|
||||
quoted_field = self.quote_identifier(field)
|
||||
|
||||
if isinstance(condition, dict):
|
||||
cond_type = condition.get("type", "eq")
|
||||
value = condition.get("value")
|
||||
|
||||
if cond_type == "like" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{value}%"
|
||||
param_counter += 1
|
||||
elif cond_type == "eq" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if case_sensitive:
|
||||
clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "ne" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} != :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "gt" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} > :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "gte" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} >= :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "lt" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} < :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "lte" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} <= :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "range" and value and isinstance(value, list) and len(value) == 2:
|
||||
param_name_start = f"param_{param_counter}"
|
||||
param_name_end = f"param_{param_counter + 1}"
|
||||
clauses.append(f"{quoted_field} BETWEEN :{param_name_start} AND :{param_name_end}")
|
||||
params[param_name_start] = value[0]
|
||||
params[param_name_end] = value[1]
|
||||
param_counter += 2
|
||||
elif cond_type == "in" and value:
|
||||
# IN 条件:value 是一个列表
|
||||
if isinstance(value, list) and len(value) > 0:
|
||||
placeholders = []
|
||||
for v in value:
|
||||
param_name = f"param_{param_counter}"
|
||||
placeholders.append(f":{param_name}")
|
||||
params[param_name] = v
|
||||
param_counter += 1
|
||||
clauses.append(f"{quoted_field} IN ({', '.join(placeholders)})")
|
||||
elif cond_type == "eq_or_null" and value is not None:
|
||||
# 等值 OR 字段为空(用于数据权限:字段为空时所有人可见)
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"({quoted_field} = :{param_name} OR {quoted_field} IS NULL)")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "in_or_null" and value:
|
||||
# IN 条件 OR 字段为空(用于数据权限:字段为空时所有人可见)
|
||||
if isinstance(value, list) and len(value) > 0:
|
||||
placeholders = []
|
||||
for v in value:
|
||||
param_name = f"param_{param_counter}"
|
||||
placeholders.append(f":{param_name}")
|
||||
params[param_name] = v
|
||||
param_counter += 1
|
||||
clauses.append(f"({quoted_field} IN ({', '.join(placeholders)}) OR {quoted_field} IS NULL)")
|
||||
elif cond_type == "space_like_and" and value:
|
||||
# 空格模糊且:按空格拆分关键词,用 AND + LIKE 连接(范围逐渐缩小)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
keyword_clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_like_or" and value:
|
||||
# 空格模糊或:按空格拆分关键词,用 OR + LIKE 连接(范围逐渐扩大)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
keyword_clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_and" and value:
|
||||
# 空格精确且:按空格拆分关键词,用 AND + = 连接(范围逐渐缩小)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = keyword
|
||||
param_counter += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_or" and value:
|
||||
# 空格精确或:按空格拆分关键词,用 OR + = 连接(范围逐渐扩大)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = keyword
|
||||
param_counter += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "null":
|
||||
clauses.append(f"{quoted_field} IS NULL")
|
||||
elif cond_type == "not_null":
|
||||
clauses.append(f"{quoted_field} IS NOT NULL")
|
||||
else:
|
||||
# 简单等值条件
|
||||
if condition is not None and condition != "":
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} = :{param_name}")
|
||||
params[param_name] = condition
|
||||
param_counter += 1
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
def _build_where(self, conditions: Dict[str, Any], start_index: int = 0) -> Tuple[str, List[Any], int]:
|
||||
"""
|
||||
构建 WHERE 子句
|
||||
|
||||
支持的条件格式:
|
||||
- {"field": value} -> field = value
|
||||
- {"field": {"type": "like", "value": "xxx"}} -> field LIKE '%xxx%'
|
||||
- {"field": {"type": "eq", "value": xxx}} -> field = xxx
|
||||
- {"field": {"type": "ne", "value": xxx}} -> field != xxx
|
||||
- {"field": {"type": "gt", "value": xxx}} -> field > xxx
|
||||
- {"field": {"type": "gte", "value": xxx}} -> field >= xxx
|
||||
- {"field": {"type": "lt", "value": xxx}} -> field < xxx
|
||||
- {"field": {"type": "lte", "value": xxx}} -> field <= xxx
|
||||
- {"field": {"type": "in", "value": [...]}} -> field IN (...)
|
||||
- {"field": {"type": "range", "value": [start, end]}} -> field BETWEEN start AND end
|
||||
- {"field": {"type": "null"}} -> field IS NULL
|
||||
- {"field": {"type": "not_null"}} -> field IS NOT NULL
|
||||
"""
|
||||
clauses = []
|
||||
params = []
|
||||
param_index = start_index
|
||||
|
||||
for field, condition in conditions.items():
|
||||
quoted_field = self.quote_identifier(field)
|
||||
|
||||
if condition is None:
|
||||
continue
|
||||
|
||||
if isinstance(condition, dict):
|
||||
cond_type = condition.get("type", "eq")
|
||||
value = condition.get("value")
|
||||
|
||||
if cond_type == "like" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{value}%")
|
||||
param_index += 1
|
||||
elif cond_type == "eq" and value is not None:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if case_sensitive:
|
||||
clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "ne" and value is not None:
|
||||
clauses.append(f"{quoted_field} != {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "gt" and value is not None:
|
||||
clauses.append(f"{quoted_field} > {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "gte" and value is not None:
|
||||
clauses.append(f"{quoted_field} >= {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "lt" and value is not None:
|
||||
clauses.append(f"{quoted_field} < {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "lte" and value is not None:
|
||||
clauses.append(f"{quoted_field} <= {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "in" and value:
|
||||
placeholders = ", ".join(self.get_placeholders(len(value), param_index))
|
||||
clauses.append(f"{quoted_field} IN ({placeholders})")
|
||||
params.extend(value)
|
||||
param_index += len(value)
|
||||
elif cond_type == "range" and value and len(value) == 2:
|
||||
clauses.append(f"{quoted_field} BETWEEN {self.get_placeholder(param_index)} AND {self.get_placeholder(param_index + 1)}")
|
||||
params.extend(value)
|
||||
param_index += 2
|
||||
elif cond_type == "space_like_and" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
keyword_clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
keyword_clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{keyword}%")
|
||||
param_index += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_like_or" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
keyword_clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
keyword_clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{keyword}%")
|
||||
param_index += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_and" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(keyword)
|
||||
param_index += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_or" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(keyword)
|
||||
param_index += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "null":
|
||||
clauses.append(f"{quoted_field} IS NULL")
|
||||
elif cond_type == "not_null":
|
||||
clauses.append(f"{quoted_field} IS NOT NULL")
|
||||
else:
|
||||
# 简单等值条件
|
||||
if condition is not None and condition != "":
|
||||
clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
params.append(condition)
|
||||
param_index += 1
|
||||
|
||||
return " AND ".join(clauses), params, param_index
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单数据操作 — 细粒度异常体系
|
||||
|
||||
异常层次:
|
||||
FormDataException (所有表单数据异常的基类)
|
||||
├── FormNotFoundException (表单不存在)
|
||||
├── RecordNotFoundException (记录不存在)
|
||||
├── FormDataValidationError (数据校验失败的基类)
|
||||
│ ├── UniqueConstraintError (唯一约束冲突)
|
||||
│ ├── NotNullConstraintError (非空约束违反)
|
||||
│ ├── CheckConstraintError (CHECK 约束违反)
|
||||
│ ├── ForeignKeyConstraintError (外键约束违反)
|
||||
│ ├── DataTypeMismatchError (数据类型不匹配)
|
||||
│ └── DataTooLongError (数据超长)
|
||||
├── FormDataSchemaError (表结构/配置错误的基类)
|
||||
│ ├── TableNotFoundError (表不存在)
|
||||
│ └── ColumnNotFoundError (列不存在)
|
||||
├── FormDataConnectionError (连接相关错误的基类)
|
||||
│ ├── DatabaseConnectionError (连接失败/断开)
|
||||
│ ├── ConnectionPoolExhaustedError (连接池耗尽)
|
||||
│ └── ConnectionTimeoutError (连接超时)
|
||||
├── FormDataConcurrencyError (并发相关错误的基类)
|
||||
│ ├── DeadlockError (死锁)
|
||||
│ └── LockTimeoutError (锁等待超时)
|
||||
├── QueryError (SQL 查询构建/执行错误)
|
||||
└── InternalDatabaseError (其他数据库内部错误)
|
||||
|
||||
每个异常类携带 error_code 和结构化的上下文信息 (context),
|
||||
方便 API 层映射到 HTTP 响应 + 前端根据 error_code 做差异化处理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 基类
|
||||
# =====================================================================
|
||||
|
||||
class FormDataException(Exception):
|
||||
"""表单数据操作异常基类"""
|
||||
|
||||
error_code: str = "FORM_DATA_ERROR"
|
||||
http_status: int = 400
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error_code: Optional[str] = None,
|
||||
http_status: Optional[int] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
if error_code is not None:
|
||||
self.error_code = error_code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
self.context: Dict[str, Any] = context or {}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 资源不存在
|
||||
# =====================================================================
|
||||
|
||||
class FormNotFoundException(FormDataException):
|
||||
"""表单定义不存在"""
|
||||
error_code = "FORM_NOT_FOUND"
|
||||
http_status = 404
|
||||
|
||||
def __init__(self, form_code: str):
|
||||
super().__init__(
|
||||
f"表单不存在: {form_code}",
|
||||
context={"form_code": form_code},
|
||||
)
|
||||
|
||||
|
||||
class RecordNotFoundException(FormDataException):
|
||||
"""记录不存在"""
|
||||
error_code = "RECORD_NOT_FOUND"
|
||||
http_status = 404
|
||||
|
||||
def __init__(self, pk: Any, *, table: str = ""):
|
||||
super().__init__(
|
||||
f"数据不存在: {pk}",
|
||||
context={"pk": str(pk), "table": table},
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 数据校验 / 约束违反
|
||||
# =====================================================================
|
||||
|
||||
class FormDataValidationError(FormDataException):
|
||||
"""数据校验失败的基类"""
|
||||
error_code = "VALIDATION_ERROR"
|
||||
http_status = 400
|
||||
|
||||
|
||||
class UniqueConstraintError(FormDataValidationError):
|
||||
"""唯一约束冲突(含重复数据信息)"""
|
||||
error_code = "UNIQUE_CONSTRAINT_VIOLATION"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "数据重复,违反唯一约束",
|
||||
*,
|
||||
fields: Optional[list[str]] = None,
|
||||
constraint_name: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
message,
|
||||
context={"fields": fields or [], "constraint_name": constraint_name},
|
||||
)
|
||||
|
||||
|
||||
class NotNullConstraintError(FormDataValidationError):
|
||||
"""非空约束违反"""
|
||||
error_code = "NOT_NULL_VIOLATION"
|
||||
|
||||
def __init__(self, column: str = ""):
|
||||
msg = f"字段 '{column}' 不能为空" if column else "存在必填字段为空"
|
||||
super().__init__(msg, context={"column": column})
|
||||
|
||||
|
||||
class CheckConstraintError(FormDataValidationError):
|
||||
"""CHECK 约束违反"""
|
||||
error_code = "CHECK_CONSTRAINT_VIOLATION"
|
||||
|
||||
def __init__(self, constraint_name: str = "", detail: str = ""):
|
||||
msg = "数据不满足校验规则"
|
||||
if constraint_name:
|
||||
msg += f" ({constraint_name})"
|
||||
super().__init__(msg, context={"constraint_name": constraint_name, "detail": detail})
|
||||
|
||||
|
||||
class ForeignKeyConstraintError(FormDataValidationError):
|
||||
"""外键约束违反(删除/更新时关联数据存在)"""
|
||||
error_code = "FOREIGN_KEY_VIOLATION"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
*,
|
||||
detail: str = "",
|
||||
referenced_table: str = "",
|
||||
constraint_name: str = "",
|
||||
):
|
||||
if not message:
|
||||
message = "操作失败,该数据被其他数据引用"
|
||||
if referenced_table:
|
||||
message = (
|
||||
f"无法删除,该数据已被「{referenced_table}」引用,"
|
||||
"请先删除或解除关联数据"
|
||||
)
|
||||
elif detail:
|
||||
message += f"({detail})"
|
||||
super().__init__(
|
||||
message,
|
||||
context={
|
||||
"detail": detail,
|
||||
"referenced_table": referenced_table,
|
||||
"constraint_name": constraint_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DataTypeMismatchError(FormDataValidationError):
|
||||
"""数据类型不匹配"""
|
||||
error_code = "DATA_TYPE_MISMATCH"
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据类型不匹配,请检查输入数据格式"
|
||||
if detail:
|
||||
msg += f"({detail})"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class DataTooLongError(FormDataValidationError):
|
||||
"""数据长度超出字段限制"""
|
||||
error_code = "DATA_TOO_LONG"
|
||||
|
||||
def __init__(self, column: str = "", max_length: int = 0):
|
||||
msg = f"字段 '{column}' 的数据过长" if column else "数据长度超出限制"
|
||||
if max_length:
|
||||
msg += f"(最大 {max_length} 个字符)"
|
||||
super().__init__(
|
||||
msg,
|
||||
context={"column": column, "max_length": max_length},
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 表结构 / 配置错误
|
||||
# =====================================================================
|
||||
|
||||
class FormDataSchemaError(FormDataException):
|
||||
"""表结构或配置相关错误基类"""
|
||||
error_code = "SCHEMA_ERROR"
|
||||
http_status = 400
|
||||
|
||||
|
||||
class TableNotFoundError(FormDataSchemaError):
|
||||
"""数据库表不存在"""
|
||||
error_code = "TABLE_NOT_FOUND"
|
||||
|
||||
def __init__(self, table: str = ""):
|
||||
msg = f"数据库表 {table} 不存在,请检查数据源配置" if table else "数据库表不存在"
|
||||
super().__init__(msg, context={"table": table})
|
||||
|
||||
|
||||
class ColumnNotFoundError(FormDataSchemaError):
|
||||
"""数据库列不存在"""
|
||||
error_code = "COLUMN_NOT_FOUND"
|
||||
|
||||
def __init__(self, column: str = "", table: str = ""):
|
||||
msg = f"数据库字段 '{column}' 不存在" if column else "数据库字段不存在"
|
||||
msg += ",请检查表单配置与数据库表结构是否一致"
|
||||
super().__init__(msg, context={"column": column, "table": table})
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 连接相关
|
||||
# =====================================================================
|
||||
|
||||
class FormDataConnectionError(FormDataException):
|
||||
"""数据库连接相关错误基类"""
|
||||
error_code = "CONNECTION_ERROR"
|
||||
http_status = 503
|
||||
|
||||
|
||||
class DatabaseConnectionError(FormDataConnectionError):
|
||||
"""数据库连接失败或断开"""
|
||||
error_code = "DB_CONNECTION_FAILED"
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库连接异常,请稍后重试"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class ConnectionPoolExhaustedError(FormDataConnectionError):
|
||||
"""连接池耗尽"""
|
||||
error_code = "CONNECTION_POOL_EXHAUSTED"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"服务器繁忙(数据库连接池已满),请稍后重试",
|
||||
)
|
||||
|
||||
|
||||
class ConnectionTimeoutError(FormDataConnectionError):
|
||||
"""连接超时"""
|
||||
error_code = "CONNECTION_TIMEOUT"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("数据库连接超时,请稍后重试")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 并发相关
|
||||
# =====================================================================
|
||||
|
||||
class FormDataConcurrencyError(FormDataException):
|
||||
"""并发相关错误基类"""
|
||||
error_code = "CONCURRENCY_ERROR"
|
||||
http_status = 409
|
||||
|
||||
|
||||
class DeadlockError(FormDataConcurrencyError):
|
||||
"""死锁"""
|
||||
error_code = "DEADLOCK"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("操作冲突(死锁),请重试")
|
||||
|
||||
|
||||
class LockTimeoutError(FormDataConcurrencyError):
|
||||
"""锁等待超时"""
|
||||
error_code = "LOCK_TIMEOUT"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("操作等待超时,请稍后重试")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 查询 / 其他
|
||||
# =====================================================================
|
||||
|
||||
class QueryError(FormDataException):
|
||||
"""SQL 查询构建或执行错误"""
|
||||
error_code = "QUERY_ERROR"
|
||||
http_status = 400
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库查询异常,请检查表单配置是否正确"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class InternalDatabaseError(FormDataException):
|
||||
"""其他未分类的数据库内部错误"""
|
||||
error_code = "INTERNAL_DB_ERROR"
|
||||
http_status = 500
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库操作失败,请联系管理员"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单管理模块
|
||||
"""
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单管理数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Index, JSON, Boolean
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class FormMeta(BaseModel):
|
||||
"""表单元数据"""
|
||||
__tablename__ = "form_meta"
|
||||
|
||||
# 所属应用(逻辑外键关联 core_application)
|
||||
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
|
||||
|
||||
name = Column(String(100), nullable=False, comment="表单名称")
|
||||
code = Column(String(100), unique=True, nullable=False, comment="表单编码")
|
||||
form_type = Column(String(20), default="normal", comment="表单类型: normal/workflow")
|
||||
description = Column(Text, default="", comment="描述")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
|
||||
# 数据源配置
|
||||
db_config = Column(String(100), nullable=False, comment="数据库配置名")
|
||||
main_table = Column(String(100), nullable=False, comment="主表名")
|
||||
main_table_schema = Column(String(100), default="", comment="主表Schema")
|
||||
main_table_database = Column(String(100), default="", comment="主表数据库")
|
||||
|
||||
# 移动端配置
|
||||
show_in_mobile = Column(Boolean, default=False, comment="是否在移动端显示")
|
||||
|
||||
# 跨应用引用
|
||||
globally_visible = Column(Boolean, default=False, comment="是否全局可见(供其他应用引用)")
|
||||
|
||||
# 图标配置
|
||||
icon = Column(String(100), default="", comment="图标")
|
||||
icon_bg_color = Column(String(200), default="", comment="图标背景色")
|
||||
|
||||
# JSON 配置
|
||||
form_config = Column(JSON, default=dict, comment="表单设计配置")
|
||||
list_config = Column(JSON, default=dict, comment="列表设计配置")
|
||||
|
||||
|
||||
class FormSubTable(BaseModel):
|
||||
"""表单子表关联"""
|
||||
__tablename__ = "form_sub_table"
|
||||
|
||||
# 所属表单(逻辑外键)
|
||||
form_id = Column(String(50), nullable=False, index=True, comment="所属表单ID")
|
||||
|
||||
table_name = Column(String(100), nullable=False, comment="从表名")
|
||||
table_schema = Column(String(100), default="", comment="从表Schema")
|
||||
table_database = Column(String(100), default="", comment="从表数据库")
|
||||
alias = Column(String(100), default="", comment="别名")
|
||||
foreign_key = Column(String(100), nullable=False, comment="外键字段")
|
||||
related_field = Column(String(100), default="id", comment="关联主表字段")
|
||||
relation_type = Column(String(20), default="one-to-many", comment="关联类型: one-to-one/one-to-many")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .service import PageService, PageServiceException
|
||||
|
||||
__all__ = ["PageService", "PageServiceException"]
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
页面管理数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, JSON
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class PageMeta(BaseModel):
|
||||
"""页面元数据"""
|
||||
__tablename__ = "page_meta"
|
||||
|
||||
# 所属应用(逻辑外键关联 core_application)
|
||||
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
|
||||
|
||||
name = Column(String(100), nullable=False, comment="页面名称")
|
||||
code = Column(String(100), unique=True, nullable=False, comment="页面编码")
|
||||
category = Column(String(50), default="", comment="分类")
|
||||
description = Column(Text, default="", comment="描述")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
|
||||
# 页面配置(存储 dashboard-design 的配置)
|
||||
page_config = Column(JSON, default=dict, comment="页面设计配置")
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
页面元数据管理服务(异步版本)
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy import select, update, delete, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from online_dev.page_manager.model import PageMeta
|
||||
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 资源类型(用于数据权限配置)
|
||||
RESOURCE_TYPE = "page"
|
||||
RESOURCE_DISPLAY_NAME = "页面管理"
|
||||
|
||||
|
||||
class PageServiceException(Exception):
|
||||
"""页面服务异常"""
|
||||
pass
|
||||
|
||||
|
||||
class PageService:
|
||||
"""
|
||||
页面元数据管理服务
|
||||
|
||||
数据权限:
|
||||
- 使用 list_with_data_scope() 自动应用数据权限
|
||||
- 支持本人、本部门、本部门及下级、全部等数据范围
|
||||
"""
|
||||
|
||||
# ============ 查询 ============
|
||||
|
||||
@staticmethod
|
||||
async def list(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
application_id: str = None,
|
||||
name: str = None,
|
||||
code: str = None,
|
||||
category: str = None,
|
||||
status: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""分页查询页面列表"""
|
||||
conditions = [PageMeta.is_deleted == False]
|
||||
|
||||
# 应用过滤
|
||||
if application_id:
|
||||
conditions.append(PageMeta.application_id == application_id)
|
||||
else:
|
||||
# 如果没有指定 application_id,只返回主应用的页面(application_id 为 NULL)
|
||||
conditions.append(PageMeta.application_id.is_(None))
|
||||
|
||||
if name:
|
||||
conditions.append(PageMeta.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(PageMeta.code.ilike(f"%{code}%"))
|
||||
if category:
|
||||
conditions.append(PageMeta.category == category)
|
||||
if status:
|
||||
conditions.append(PageMeta.status == status)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(PageMeta.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(PageMeta).where(and_(*conditions)).order_by(
|
||||
PageMeta.sort, PageMeta.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def list_with_data_scope(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
application_id: str = None,
|
||||
name: str = None,
|
||||
code: str = None,
|
||||
category: str = None,
|
||||
status: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
分页查询页面列表(带数据权限过滤)
|
||||
|
||||
自动从上下文获取当前用户信息,应用数据权限过滤
|
||||
"""
|
||||
conditions = [PageMeta.is_deleted == False]
|
||||
|
||||
# 应用过滤
|
||||
if application_id:
|
||||
conditions.append(PageMeta.application_id == application_id)
|
||||
else:
|
||||
conditions.append(PageMeta.application_id.is_(None))
|
||||
|
||||
if name:
|
||||
conditions.append(PageMeta.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(PageMeta.code.ilike(f"%{code}%"))
|
||||
if category:
|
||||
conditions.append(PageMeta.category == category)
|
||||
if status:
|
||||
conditions.append(PageMeta.status == status)
|
||||
|
||||
# 获取数据权限过滤条件并应用
|
||||
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
|
||||
scope_conditions = apply_data_scope_to_conditions(PageMeta, data_scope_filter)
|
||||
conditions.extend(scope_conditions)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(PageMeta.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(PageMeta).where(and_(*conditions)).order_by(
|
||||
PageMeta.sort, PageMeta.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get(db: AsyncSession, page_id: str) -> PageMeta:
|
||||
"""获取页面详情"""
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.id == page_id,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
page = result.scalar_one_or_none()
|
||||
|
||||
if not page:
|
||||
raise PageServiceException(f"页面不存在: {page_id}")
|
||||
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
async def get_by_code(db: AsyncSession, code: str) -> PageMeta:
|
||||
"""根据编码获取页面"""
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
page = result.scalar_one_or_none()
|
||||
|
||||
if not page:
|
||||
raise PageServiceException(f"页面不存在: {code}")
|
||||
|
||||
return page
|
||||
|
||||
# ============ 创建 ============
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""创建页面"""
|
||||
code = data.get("code")
|
||||
|
||||
# 检查编码唯一性
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise PageServiceException(f"页面编码已存在: {code}")
|
||||
|
||||
# 从上下文获取用户信息
|
||||
from utils.context import get_current_user_info_from_context
|
||||
user_info = get_current_user_info_from_context()
|
||||
|
||||
page = PageMeta(
|
||||
application_id=data.get("application_id"),
|
||||
name=data.get("name"),
|
||||
code=code,
|
||||
category=data.get("category", ""),
|
||||
description=data.get("description", ""),
|
||||
page_config=data.get("page_config", {}),
|
||||
sort=data.get("sort", 0),
|
||||
sys_creator_id=user_id or (user_info.get('user_id') if user_info else None),
|
||||
sys_modifier_id=user_id or (user_info.get('user_id') if user_info else None),
|
||||
)
|
||||
|
||||
# 自动填充部门ID
|
||||
if user_info and user_info.get('dept_id'):
|
||||
page.sys_dept_id = user_info.get('dept_id')
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info(f"页面创建成功: {page.code}")
|
||||
return page
|
||||
|
||||
# ============ 更新 ============
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""更新页面"""
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
# 更新基本字段
|
||||
if "name" in data and data["name"] is not None:
|
||||
page.name = data["name"]
|
||||
if "category" in data and data["category"] is not None:
|
||||
page.category = data["category"]
|
||||
if "description" in data and data["description"] is not None:
|
||||
page.description = data["description"]
|
||||
if "sort" in data and data["sort"] is not None:
|
||||
page.sort = data["sort"]
|
||||
if "page_config" in data and data["page_config"] is not None:
|
||||
page.page_config = data["page_config"]
|
||||
|
||||
page.sys_modifier_id = user_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info(f"页面更新成功: {page.code}")
|
||||
return page
|
||||
|
||||
# ============ 删除 ============
|
||||
|
||||
@staticmethod
|
||||
async def _cleanup_page_publish_resources(db: AsyncSession, page: PageMeta) -> None:
|
||||
"""清理页面发布产生的菜单(取消发布、删除时共用)"""
|
||||
from core.menu.model import Menu
|
||||
|
||||
delete_menu_stmt = delete(Menu).where(
|
||||
Menu.path == f"/page-render/{page.code}"
|
||||
)
|
||||
result = await db.execute(delete_menu_stmt)
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
"物理删除页面菜单: %s, 删除数量: %s",
|
||||
page.code,
|
||||
result.rowcount,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, page_id: str) -> bool:
|
||||
"""删除页面(软删除)"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
page.is_deleted = True
|
||||
page.status = "draft"
|
||||
await db.commit()
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("页面删除成功: %s", page.code)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def batch_delete(db: AsyncSession, page_ids: List[str]) -> int:
|
||||
"""批量删除页面"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.id.in_(page_ids),
|
||||
PageMeta.is_deleted == False,
|
||||
)
|
||||
pages = list((await db.execute(stmt)).scalars().all())
|
||||
for page in pages:
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
|
||||
update_stmt = update(PageMeta).where(
|
||||
PageMeta.id.in_(page_ids),
|
||||
PageMeta.is_deleted == False,
|
||||
).values(is_deleted=True, status="draft")
|
||||
result = await db.execute(update_stmt)
|
||||
await db.commit()
|
||||
|
||||
count = result.rowcount
|
||||
if count > 0:
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("批量删除页面成功: %s 个", count)
|
||||
return count
|
||||
|
||||
# ============ 发布/取消发布 ============
|
||||
|
||||
@staticmethod
|
||||
async def publish(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
publish_config: Dict[str, Any] = None
|
||||
) -> PageMeta:
|
||||
"""发布页面并创建菜单"""
|
||||
from core.menu.model import Menu
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
if page.status == "published":
|
||||
raise PageServiceException("页面已发布")
|
||||
|
||||
# 更新页面状态
|
||||
page.status = "published"
|
||||
page.version += 1
|
||||
|
||||
# 创建或更新菜单
|
||||
if publish_config:
|
||||
menu_parent_id = publish_config.get("menu_parent_id")
|
||||
menu_path = f"/page-render/{page.code}"
|
||||
|
||||
# 检查是否已存在该页面的菜单
|
||||
menu_stmt = select(Menu).where(
|
||||
Menu.path == menu_path
|
||||
)
|
||||
menu_result = await db.execute(menu_stmt)
|
||||
existing_menu = menu_result.scalar_one_or_none()
|
||||
|
||||
if existing_menu:
|
||||
# 更新现有菜单
|
||||
existing_menu.name = publish_config.get("menu_name", page.name)
|
||||
existing_menu.title = publish_config.get("menu_name", page.name)
|
||||
existing_menu.parent_id = menu_parent_id
|
||||
existing_menu.icon = publish_config.get("menu_icon", "lucide:layout-dashboard")
|
||||
existing_menu.order = publish_config.get("menu_order", 0)
|
||||
existing_menu.type = "online_page"
|
||||
existing_menu.application_id = page.application_id
|
||||
logger.info(f"更新页面菜单: {page.code}")
|
||||
else:
|
||||
# 创建新菜单
|
||||
new_menu = Menu(
|
||||
application_id=page.application_id,
|
||||
name=publish_config.get("menu_name", page.name),
|
||||
title=publish_config.get("menu_name", page.name),
|
||||
path=menu_path,
|
||||
component="online-dev/page-render/index",
|
||||
type="online_page",
|
||||
parent_id=menu_parent_id,
|
||||
icon=publish_config.get("menu_icon", "lucide:layout-dashboard"),
|
||||
order=publish_config.get("menu_order", 0),
|
||||
)
|
||||
db.add(new_menu)
|
||||
logger.info(f"创建页面菜单: {page.code}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
# 清空菜单缓存
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("已清空菜单缓存")
|
||||
|
||||
logger.info(f"页面发布成功: {page.code}, version={page.version}")
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
async def unpublish(db: AsyncSession, page_id: str) -> PageMeta:
|
||||
"""取消发布页面并物理删除菜单"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
if page.status == "draft":
|
||||
raise PageServiceException("页面未发布")
|
||||
|
||||
page.status = "draft"
|
||||
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
# 清空菜单缓存
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("已清空菜单缓存")
|
||||
|
||||
logger.info("页面取消发布: %s", page.code)
|
||||
return page
|
||||
|
||||
# ============ 复制 ============
|
||||
|
||||
@staticmethod
|
||||
async def copy(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
new_code: str,
|
||||
new_name: str = None,
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""复制页面"""
|
||||
source = await PageService.get(db, page_id)
|
||||
|
||||
# 检查新编码唯一性
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == new_code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise PageServiceException(f"页面编码已存在: {new_code}")
|
||||
|
||||
new_page = PageMeta(
|
||||
application_id=source.application_id,
|
||||
name=new_name or f"{source.name}_副本",
|
||||
code=new_code,
|
||||
category=source.category,
|
||||
description=source.description,
|
||||
status="draft",
|
||||
version=1,
|
||||
page_config=source.page_config,
|
||||
sort=source.sort,
|
||||
sys_creator_id=user_id,
|
||||
sys_modifier_id=user_id,
|
||||
)
|
||||
db.add(new_page)
|
||||
await db.commit()
|
||||
await db.refresh(new_page)
|
||||
|
||||
logger.info(f"页面复制成功: {source.code} -> {new_code}")
|
||||
return new_page
|
||||
|
||||
# ============ 导入/导出 ============
|
||||
|
||||
@staticmethod
|
||||
async def export_config(db: AsyncSession, page_id: str) -> Dict[str, Any]:
|
||||
"""导出页面配置"""
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
return {
|
||||
"name": page.name,
|
||||
"code": page.code,
|
||||
"category": page.category,
|
||||
"description": page.description,
|
||||
"page_config": page.page_config,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def check_import(db: AsyncSession, code: str) -> Dict[str, Any]:
|
||||
"""导入预检查:编码是否冲突"""
|
||||
code_exists = False
|
||||
if code:
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
code_exists = result.scalar_one_or_none() is not None
|
||||
|
||||
return {
|
||||
"code_exists": code_exists,
|
||||
"can_import": not code_exists,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def import_config(
|
||||
db: AsyncSession,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""导入页面配置"""
|
||||
required_fields = ["name", "code"]
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
raise PageServiceException(f"缺少必要字段: {field}")
|
||||
|
||||
return await PageService.create(db, data, user_id)
|
||||
|
||||
# ============ 获取分类列表 ============
|
||||
|
||||
@staticmethod
|
||||
async def get_categories(db: AsyncSession) -> List[str]:
|
||||
"""获取所有分类"""
|
||||
stmt = select(PageMeta.category).where(
|
||||
PageMeta.is_deleted == False,
|
||||
PageMeta.category != ""
|
||||
).distinct()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
Reference in New Issue
Block a user