""""统一 CRUD 基类 — 同时支持有认证和无认证场景 - 当 `auth` 不为 None 时(Controller 注入):自动处理租户隔离、数据权限过滤、审计字段填充 - 当 `auth` 为 None 时(后台任务/脚本):纯数据操作,无权限/租户过滤 用法: # 带认证(Controller) class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): def __init__(self, auth: AuthSchema) -> None: super().__init__(model=UserModel, auth=auth) # 无认证(后台任务) class OrderCRUD(CRUDBase[OrderModel, Any, Any]): def __init__(self, session: AsyncSession) -> None: super().__init__(model=OrderModel, session=session) """ from collections.abc import Sequence from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel from sqlalchemy import Select, asc, delete, desc, false, func, literal_column, select, update from sqlalchemy import inspect as sa_inspect from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.sql.elements import ColumnElement from app.core.base_model import MappedBase from app.core.base_schema import AuthSchema, PageResultSchema from app.core.exceptions import CustomException from app.core.permission import Permission if TYPE_CHECKING: from sqlalchemy.engine import Result OutSchemaType = TypeVar("OutSchemaType", bound=BaseModel) class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaType: BaseModel]: """统一数据层基类 — 有认证时自动处理权限,无认证时纯数据操作""" def __init__( self, model: type[ModelType], auth: AuthSchema | None = None, session: AsyncSession | None = None, ) -> None: """ 初始化 CRUDBase。 参数: - model: 数据模型类 - auth: 认证信息(Controller 场景),为 None 时跳过所有权限/租户/审计逻辑 - session: 数据库会话(后台任务场景),仅在 auth=None 时使用 """ self.model = model self.auth = auth self.db = session if session is not None else (auth.db if auth else None) if self.db is None: raise RuntimeError( "CRUDBase 未初始化数据库会话,请传入 auth 或 session 参数" ) def _get_pk_col(self) -> ColumnElement: """获取模型主键列""" mapper = sa_inspect(self.model) pk_cols = list(getattr(mapper, "primary_key", [])) if not pk_cols: raise CustomException(msg="模型缺少主键") if len(pk_cols) > 1: raise CustomException(msg="暂不支持复合主键操作") return pk_cols[0] @property def _supports_soft_delete(self) -> bool: """模型是否支持软删除""" return all( hasattr(self.model, attr) for attr in ("is_deleted", "deleted_time", "deleted_id") ) def _soft_delete_values(self) -> dict[str, Any]: """软删除时需要更新的字段值""" data: dict[str, Any] = {"is_deleted": True, "deleted_time": datetime.now()} if self.auth and self.auth.user: data["deleted_id"] = self.auth.user.id return data async def _get_one( self, preload: list[str | Any] | None = None, **kwargs ) -> ModelType | None: """内部方法:在当前实例会话上执行单条查询(get / update 共用) 参数: - preload: 预加载关系 - **kwargs: 查询条件 返回: - 对象实例或 None """ conditions = await self.__build_conditions(**kwargs) sql = select(self.model).where(*conditions) for opt in self.__loader_options(preload): sql = sql.options(opt) sql = await self.__filter_permissions(sql) result: Result = await self.db.execute(sql) return result.scalars().first() async def get(self, preload: list[str | Any] | None = None, **kwargs) -> ModelType | None: """ 根据条件获取单个对象(复用请求级事务会话,保证读已写一致性) 参数: - preload: 预加载关系 - **kwargs: 查询条件 返回: - 对象实例或 None """ try: return await self._get_one(preload=preload, **kwargs) except CustomException: raise except Exception as e: raise CustomException(msg=f"获取查询失败: {e!s}") async def get_by_id(self, model_id: int) -> ModelType | None: """按主键查询""" return await self.get(id=model_id) async def get_or_404( self, id: int | None = None, msg: str = "该数据不存在", preload: list[str | Any] | None = None, out_schema: type[OutSchemaType] | None = None, **kwargs, ) -> ModelType | OutSchemaType: """ 按条件查询单条记录,不存在时抛出 404。 参数: - id: 主键 ID(快捷方式,等价于 kwargs={"id": id})。 - msg: 不存在时的错误消息。 - preload: 预加载关系列表。 - out_schema: 输出 Schema,为 None 时返回 ORM 对象。 - **kwargs: 其他查询条件(与 id 互斥)。 返回: - ORM 对象或 Pydantic Schema 实例。 异常: - CustomException: 记录不存在。 """ if id is not None: kwargs["id"] = id obj = await self.get(preload=preload, **kwargs) if not obj: raise CustomException(msg=msg) return out_schema.model_validate(obj) if out_schema else obj async def exists(self, **kwargs) -> bool: """ 检查是否存在符合条件的记录 参数: - **kwargs: 查询条件 返回: - 是否存在 """ return await self.get(**kwargs) is not None async def count(self, **kwargs) -> int: """ 统计符合条件的记录数(复用请求级事务会话) 参数: - **kwargs: 查询条件,支持元组语法 返回: - 记录数 """ try: conditions = await self.__build_conditions(**kwargs) count_sql = select(func.count()).select_from(self.model).where(*conditions) count_sql = await self.__filter_permissions(count_sql) result: Result = await self.db.execute(count_sql) return result.scalar() or 0 except CustomException: raise except Exception as e: raise CustomException(msg=f"统计失败: {e!s}") async def get_list( self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None, ) -> Sequence[ModelType]: """ 根据条件获取对象列表(复用请求级事务会话) 参数: - search: 查询条件 - order_by: 排序字段, 格式为 [{'id': 'asc'}, {'name': 'desc'}] - preload: 预加载关系 返回: - 对象列表 """ try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] sql = select(self.model).where(*conditions).order_by(*self._parse_order(order)) for opt in self.__loader_options(preload): sql = sql.options(opt) sql = await self.__filter_permissions(sql) result: Result = await self.db.execute(sql) return result.scalars().all() except CustomException: raise except Exception as e: raise CustomException(msg=f"列表查询失败: {e!s}") async def tree_list( self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, children_attr: str | None = None, preload: list[str | Any] | None = None, ) -> Sequence[ModelType]: """ 获取树形结构数据列表(复用请求级事务会话) 参数: - search: 查询条件 - order_by: 排序字段 - children_attr: 子节点属性名(None 时自动从模型 __tree_children_attr__ 推断) - preload: 额外预加载关系 返回: - 树形结构数据列表 """ # 自动从模型推断 children_attr if children_attr is None: children_attr = getattr(self.model, "__tree_children_attr__", "children") try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] sql = select(self.model).where(*conditions).order_by(*self._parse_order(order)) final_preload = preload if preload is None and children_attr and hasattr(self.model, children_attr): model_defaults = getattr(self.model, "__loader_options__", []) final_preload = [*list(model_defaults), children_attr] for opt in self.__loader_options(final_preload): sql = sql.options(opt) sql = await self.__filter_permissions(sql) result: Result = await self.db.execute(sql) return result.scalars().all() except CustomException: raise except Exception as e: raise CustomException(msg=f"树形列表查询失败: {e!s}") async def page( self, offset: int, limit: int, order_by: list[dict[str, str]], search: dict, out_schema: type[OutSchemaType] | None = None, preload: list[str | Any] | None = None, ) -> PageResultSchema: """ 获取分页数据(复用请求级事务会话;count 与 data 共享同一会话) 参数: - offset: 偏移量 - limit: 每页数量 - order_by: 排序字段 - search: 查询条件 - out_schema: 输出数据模型(None 时返回原始 ORM 对象) - preload: 预加载关系 返回: - PageResultSchema: 分页结果 """ try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] mapper = sa_inspect(self.model) pk_cols = list(getattr(mapper, "primary_key", [])) pk = pk_cols[0] if pk_cols else literal_column("1") data_sql = select(self.model).where(*conditions) for opt in self.__loader_options(preload): data_sql = data_sql.options(opt) data_sql = await self.__filter_permissions(data_sql) count_sql = select(func.count(pk)).select_from(self.model) where_clause = data_sql.whereclause if where_clause is not None: count_sql = count_sql.where(where_clause) total_result = await self.db.execute(count_sql) total = total_result.scalar() or 0 result: Result = await self.db.execute( data_sql.order_by(*self._parse_order(order)).offset(offset).limit(limit) ) objs = result.scalars().all() items = ( [out_schema.model_validate(obj).model_dump() for obj in objs] if out_schema else list(objs) ) return PageResultSchema( page_no=offset // limit + 1 if limit else 1, page_size=limit or 10, total=total, has_next=offset + limit < total, items=items, ) except CustomException: raise except Exception as e: raise CustomException(msg=f"分页查询失败: {e!s}") async def create(self, data: CreateSchemaType) -> ModelType: """创建新对象(有认证时自动填充租户与审计字段) 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - data: 对象属性 返回: - 新创建的对象实例 """ try: obj_dict = data if isinstance(data, dict) else data.model_dump() obj = self.model(**obj_dict) if self.auth and self.auth.user: if hasattr(obj, "tenant_id"): # 非超管始终使用当前租户;超管仅当未显式指定时自动填充 if not self.auth.user.is_superuser or getattr(obj, "tenant_id", None) is None: setattr(obj, "tenant_id", self.auth.tenant_id or self.auth.user.tenant_id) if hasattr(obj, "created_id"): setattr(obj, "created_id", self.auth.user.id) if hasattr(obj, "updated_id"): setattr(obj, "updated_id", self.auth.user.id) self.db.add(obj) await self.db.flush() await self.db.refresh(obj) return obj except CustomException: raise except Exception as e: raise CustomException(msg=f"创建失败: {e!s}") async def update(self, id: int, data: UpdateSchemaType) -> ModelType: """更新对象(有认证时检查租户归属 + 填充审计字段) 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 参数: - id: 对象 ID - data: 更新属性 返回: - 更新后的对象实例 """ try: obj_dict = data if isinstance(data, dict) else data.model_dump(exclude_unset=True, exclude={"id"}) model_defaults = getattr(self.model, "__loader_options__", []) obj = await self._get_one(id=id, preload=model_defaults) if not obj: raise CustomException(msg="更新对象不存在") # 租户权限检查(仅在有认证且非超管时) if self.auth and self.auth.user and not self.auth.user.is_superuser: if hasattr(obj, "tenant_id"): obj_tid = getattr(obj, "tenant_id", None) if obj_tid is not None and obj_tid != self.auth.user.tenant_id: is_platform = getattr(self.model, "__platform_data_shared__", False) if is_platform and obj_tid == 1: raise CustomException(msg="平台数据仅管理员可修改") raise CustomException(msg="无权修改其他租户的数据") # 审计字段 if self.auth and self.auth.user and hasattr(obj, "updated_id"): setattr(obj, "updated_id", self.auth.user.id) for key, value in obj_dict.items(): if hasattr(obj, key): setattr(obj, key, value) await self.db.flush() await self.db.refresh(obj) return obj except CustomException: raise except Exception as e: raise CustomException(msg=f"更新失败: {e!s}") async def delete(self, ids: list[int]) -> None: """软删除对象(有认证时填充删除人 + 租户隔离)""" try: pk = self._get_pk_col() if self._supports_soft_delete: sql = self._tenant_dml_where( update(self.model).where(pk.in_(ids)) ).values(**self._soft_delete_values()) await self.db.execute(sql) else: sql = self._tenant_dml_where( delete(self.model).where(pk.in_(ids)) ) await self.db.execute(sql) await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"删除失败: {e!s}") async def clear(self) -> None: """软清空对象表(有认证时填充删除人 + 租户隔离)""" try: if self._supports_soft_delete: sql = self._tenant_dml_where(update(self.model)).values(**self._soft_delete_values()) await self.db.execute(sql) else: sql = self._tenant_dml_where(delete(self.model)) await self.db.execute(sql) await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"清空失败: {e!s}") async def set(self, ids: list[int], **kwargs) -> None: """批量更新字段(带租户隔离)""" try: pk = self._get_pk_col() sql = self._tenant_dml_where(update(self.model)).where(pk.in_(ids)).values(**kwargs) await self.db.execute(sql) await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"批量更新失败: {e!s}") async def restore(self, ids: list[int]) -> None: """恢复软删除对象(带租户隔离)""" try: if not self._supports_soft_delete: raise CustomException(msg="该模型不支持软删除,无法恢复") pk = self._get_pk_col() sql = self._tenant_dml_where( update(self.model).where(pk.in_(ids)) ).values(is_deleted=False, deleted_time=None, deleted_id=None) await self.db.execute(sql) await self.db.flush() except CustomException: raise except Exception as e: raise CustomException(msg=f"恢复失败: {e!s}") async def __filter_permissions(self, sql: Select) -> Select: """过滤数据权限(仅用于 Select)""" if not self.auth: return sql if getattr(self.model, "__platform_data_shared__", False): for condition in self._platform_shared_conditions(): sql = sql.where(condition) filter_obj = Permission(model=self.model, auth=self.auth) return await filter_obj.filter_query(sql) def _platform_shared_conditions(self) -> list[ColumnElement]: if not self.auth or not self.auth.user: return [] tid = self.auth.user.tenant_id if tid is not None and tid != 1: return [ (getattr(self.model, "tenant_id") == tid) | (getattr(self.model, "tenant_id") == 1) ] return [] def _tenant_dml_where(self, sql): """为 DML 语句注入 tenant_id 条件(不读平台数据)""" if self.auth and hasattr(self.model, "tenant_id") \ and self.auth.user and not self.auth.user.is_superuser: tid = self.auth.tenant_id if tid is not None: return sql.where(getattr(self.model, "tenant_id") == tid) return sql async def __build_conditions(self, **kwargs) -> list[ColumnElement]: conditions: list[ColumnElement] = [] if hasattr(self.model, "is_deleted"): conditions.append(getattr(self.model, "is_deleted") == False) # noqa: E712 if self.auth and hasattr(self.model, "tenant_id") \ and not getattr(self.model, "__platform_data_shared__", False): if self.auth.user and not self.auth.user.is_superuser: tid = self.auth.tenant_id if tid is not None: conditions.append(getattr(self.model, "tenant_id") == tid) for key, value in kwargs.items(): if value is None or value == "": continue attr = getattr(self.model, key) if isinstance(value, tuple): seq, val = value if seq == "None": conditions.append(attr.is_(None)) elif seq == "not None": conditions.append(attr.isnot(None)) elif seq == "date" and val: dt = datetime.strptime(val, "%Y-%m-%d") conditions.append(attr >= dt) conditions.append(attr < dt + timedelta(days=1)) elif seq == "month" and val: dt = datetime.strptime(val, "%Y-%m") next_month = ( dt.replace(year=dt.year + 1, month=1) if dt.month == 12 else dt.replace(month=dt.month + 1) ) conditions.append(attr >= dt) conditions.append(attr < next_month) elif seq == "like" and val: conditions.append(attr.like(f"%{val}%")) elif seq == "in": if val is None: continue if isinstance(val, (list, tuple, set)) and len(val) == 0: conditions.append(false()) else: conditions.append(attr.in_(val)) elif seq == "between" and isinstance(val, (list, tuple)) and len(val) == 2: conditions.append(attr.between(val[0], val[1])) elif seq in ("!=", "ne") and val is not None: conditions.append(attr != val) elif seq in (">", "gt") and val is not None: conditions.append(attr > val) elif seq in (">=", "ge") and val is not None: conditions.append(attr >= val) elif seq in ("<", "lt") and val is not None: conditions.append(attr < val) elif seq in ("<=", "le") and val is not None: conditions.append(attr <= val) elif seq in ("eq", "==") and val is not None: conditions.append(attr == val) else: conditions.append(attr == value) return conditions def _parse_order( self, order: list[dict[str, str]] ) -> list[ColumnElement]: """ 解析排序参数 参数: - order: 排序字段列表, 格式为 [{'id': 'asc'}, {'name': 'desc'}] 返回: - 排序表达式列表 """ columns: list[ColumnElement] = [] for item in order: for field, direction in item.items(): column = getattr(self.model, field) columns.append(desc(column) if direction.lower() == "desc" else asc(column)) return columns def __loader_options( self, preload: list[str | Any] | None = None ) -> list[Any]: """ 构建预加载选项 参数: - preload: 预加载关系,支持关系名字符串或 SQLAlchemy loader option 返回: - 预加载选项列表 """ options: list[Any] = [] model_loader_options = getattr(self.model, "__loader_options__", []) all_preloads: set[str | Any] = set(model_loader_options) if preload: for opt in preload: if isinstance(opt, str): all_preloads.add(opt) elif preload == []: all_preloads = set() for opt in all_preloads: if isinstance(opt, str): if hasattr(self.model, opt): options.append(selectinload(getattr(self.model, opt))) else: options.append(opt) return options