Restore AI workflow design nodes

This commit is contained in:
2026-06-09 21:18:33 +08:00
parent fbd726ebdf
commit cb45c0a068
71 changed files with 22676 additions and 21 deletions
@@ -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):
"""平台 AsyncSessiondb_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.tablemain_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}"
# 使用 ILIKEPostgreSQL)或 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()]