Files
FastapiAdmin/backend/app/core/base_crud.py
T
zhangtao 0ce31936aa chore: 清理冗余代码与配置,优化项目结构
1.  删除无用文件与废弃代码:移除locale枚举、element-plus插件、sse路由、api token模块等
2.  简化类型导入与依赖:移除大量未使用的类型导入,统一echarts导入方式
3.  优化配置与样式:调整gitignore、样式引入顺序,新增列表动画样式
4.  修复接口与模型:修正接口返回类型、查询参数配置,更新部门模型字段
5.  优化性能与体验:添加图片懒加载,优化加载逻辑与表格渲染
6.  调整环境配置:新增并更新开发/生产环境配置文件
2026-07-23 20:49:52 +08:00

472 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any, TypeVar, cast
from pydantic import BaseModel
from sqlalchemy import asc, delete, desc, false, func, select, true, update
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.engine import Result
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, load_only, selectinload
from sqlalchemy.sql.elements import ColumnElement
from app.core.base_model import ModelMixin
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
OutSchemaType = TypeVar("OutSchemaType", bound=BaseModel)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
# 操作符 → 方法名映射,留给 _resolve_condition 运行时根据具体 attr 调用
# 因为不同列的 ColumnElement 类型不同,不能提取为类级常量
_OPERATOR_MAP: dict[str, str] = {
"!=": "__ne__", "ne": "__ne__",
">": "__gt__", "gt": "__gt__",
">=": "__ge__", "ge": "__ge__",
"<": "__lt__", "lt": "__lt__",
"<=": "__le__", "le": "__le__",
"eq": "__eq__", "==": "__eq__",
}
class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
"""事务边界在 HTTP 层(db_getter 有 session.begin()),CRUD 只 flush 不 commit。
CRUD 层只自动填充 created_id/updated_id,不按这些字段过滤数据。
数据权限由 Service 层负责 —— Service 层忘记过滤 = 越权风险。
"""
def __init__(self, model: type[ModelType], auth: AuthSchema, db: AsyncSession) -> None:
self.model = model
self.auth = auth
self.db = db
# ── 辅助方法 ──────────────────────────────────────────────────────
def _get_pk_col(self) -> ColumnElement:
"""获取模型的主键列。delete/set/restore/page 批量操作共用。"""
mapper = sa_inspect(self.model)
pk_cols = list(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:
# 判断模型是否有 is_deleted / deleted_time / deleted_id 三个字段
return all(hasattr(self.model, attr) for attr in ("is_deleted", "deleted_time", "deleted_id"))
def _soft_delete_values(self) -> dict[str, Any]:
"""返回 UPDATE 设置软删除字段所需的 values 字典。"""
data: dict[str, Any] = {"is_deleted": True, "deleted_time": datetime.now(UTC)}
if self.auth.user.id:
data["deleted_id"] = self.auth.user.id
return data
# ── 查询 ──────────────────────────────────────────────────────────
async def get(self, preload: list[str | Any] | None = None, include_deleted: bool = False, **kwargs) -> ModelType | None:
"""单条查询。**kwargs 按字段名 = 值传参,自动转 WHERE 条件。"""
try:
conditions = await self._build_conditions(include_deleted=include_deleted, **kwargs)
sql = select(self.model).where(*conditions)
for opt in self._loader_options(preload):
sql = sql.options(opt)
result: Result = await self.db.execute(sql)
return result.scalars().first()
except Exception as e:
raise CustomException(msg=f"获取查询失败: {e!s}") from e
async def get_or_404(
self,
id: int | None = None,
msg: str = "该数据不存在",
preload: list[str | Any] | None = None,
out_schema: type[OutSchemaType] | None = None,
include_deleted: bool = False,
**kwargs,
) -> ModelType | OutSchemaType:
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。"""
if id is not None:
kwargs["id"] = id
obj = await self.get(preload=preload, include_deleted=include_deleted, **kwargs)
if not obj:
raise CustomException(msg=msg)
return out_schema.model_validate(obj) if out_schema else obj
async def exists(self, include_deleted: bool = False, **kwargs) -> bool:
# 用 COUNT 代替 SELECT,避免加载整行数据和关联关系
return await self.count(include_deleted=include_deleted, **kwargs) > 0
async def count(self, include_deleted: bool = False, **kwargs) -> int:
"""统计行数。"""
try:
conditions = await self._build_conditions(include_deleted=include_deleted, **kwargs)
count_sql = select(func.count()).select_from(self.model).where(*conditions)
result: Result = await self.db.execute(count_sql)
return result.scalar() or 0
except Exception as e:
raise CustomException(msg=f"统计失败: {e!s}") from e
async def get_list(
self,
search: dict[str, Any] | None = None,
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
load_columns: list | None = None,
include_deleted: bool = False,
) -> Sequence[ModelType]:
"""不分页的列表查询。"""
try:
conditions = await self._build_conditions(include_deleted=include_deleted, **(search or {}))
order = order_by or [{"id": "asc"}]
sql = select(self.model).where(*conditions).order_by(*self._parse_order(order))
if load_columns:
sql = sql.options(load_only(*load_columns))
for opt in self._loader_options(preload):
sql = sql.options(opt)
result: Result = await self.db.execute(sql)
return result.scalars().all()
except Exception as e:
raise CustomException(msg=f"列表查询失败: {e!s}") from e
async def page(
self,
offset: int,
limit: int,
order_by: list[dict[str, str]],
search: dict[str, Any] | None = None,
out_schema: type[OutSchemaType] | None = None,
preload: list[str | Any] | None = None,
load_columns: list | None = None,
include_deleted: bool = False,
) -> PageResultSchema[OutSchemaType] | PageResultSchema:
"""分页查询。COUNT + 数据分两趟查,COUNT 复用 WHERE 但不带 loading options。"""
try:
conditions = await self._build_conditions(include_deleted=include_deleted, **(search or {}))
order = order_by or [{"id": "asc"}]
pk = self._get_pk_col() # COUNT 用主键列更精确
data_sql = select(self.model).where(*conditions)
if load_columns:
data_sql = data_sql.options(load_only(*load_columns))
for opt in self._loader_options(preload):
data_sql = data_sql.options(opt)
# 从 data_sql 提取 WHERE,构造独立的 COUNT 查询(去掉 loader option,避免 LEFT JOIN 开销)
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) 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 Exception as e:
raise CustomException(msg=f"分页查询失败: {e!s}") from e
# ── 写入 ──────────────────────────────────────────────────────────
async def create(self, data: CreateSchemaType | dict[str, Any]) -> ModelType:
"""新增记录。"""
try:
obj_dict = data.model_dump(exclude_none=True) if isinstance(data, BaseModel) else cast("dict[str, Any]", data)
obj = self.model(**obj_dict)
user = self.auth.user
if user.id:
# 自动填充审计人,hasattr 兼容无审计字段的模型
if hasattr(obj, "created_id"):
setattr(obj, "created_id", user.id)
if hasattr(obj, "updated_id"):
setattr(obj, "updated_id", user.id)
self.db.add(obj)
await self.db.flush()
await self.db.refresh(obj)
preload_options = []
mapper = sa_inspect(self.model)
if hasattr(mapper, "relationships"):
for rel_name in ("created_by", "updated_by"):
if rel_name in mapper.relationships:
preload_options.append(joinedload(getattr(self.model, rel_name)))
if preload_options:
result = await self.db.execute(
select(self.model).options(*preload_options).where(self._get_pk_col() == obj.id)
)
obj = result.scalar_one()
return obj
except Exception as e:
raise CustomException(msg=f"创建失败: {e!s}") from e
async def update(self, id: int, data: UpdateSchemaType | dict[str, Any]) -> ModelType:
"""更新记录。用 exclude_unset / exclude_none 准确表达前端意图。"""
try:
obj_dict = data.model_dump(exclude_unset=True, exclude_none=True, exclude={"id"}) if isinstance(data, BaseModel) else cast("dict[str, Any]", data)
obj = await self.get(id=id)
if not obj:
raise CustomException(msg="更新对象不存在")
# 更新操作自动更新 updated_id
user = self.auth.user
if user.id and hasattr(obj, "updated_id"):
setattr(obj, "updated_id", 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)
preload_options = []
mapper = sa_inspect(self.model)
if hasattr(mapper, "relationships"):
for rel_name in ("created_by", "updated_by"):
if rel_name in mapper.relationships:
preload_options.append(joinedload(getattr(self.model, rel_name)))
if preload_options:
result = await self.db.execute(
select(self.model).options(*preload_options).where(self._get_pk_col() == obj.id)
)
obj = result.scalar_one()
return obj
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"更新失败: {e!s}") from e
async def delete(self, ids: list[int]) -> None:
"""软删除优先,无软删除则物理删除。"""
try:
pk = self._get_pk_col()
if self._supports_soft_delete:
# 加 is_deleted=false() 条件,防止重复软删除(幂等)
sql = update(self.model).where(pk.in_(ids)).where(
getattr(self.model, "is_deleted") == false()
).values(**self._soft_delete_values())
else:
sql = delete(self.model).where(pk.in_(ids))
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"删除失败: {e!s}") from e
async def clear(self) -> None:
"""清空整表。软删除模式下相当于"回收站清空",只清理已删标记的记录。"""
try:
if self._supports_soft_delete:
sql = update(self.model).where(
getattr(self.model, "is_deleted") == true()
).values(**self._soft_delete_values())
else:
sql = delete(self.model)
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"清空失败: {e!s}") from e
async def set(self, ids: list[int], include_deleted: bool = False, **kwargs) -> None:
"""批量更新。软删除模式下默认跳过已删除的记录。"""
try:
pk = self._get_pk_col()
sql = update(self.model).where(pk.in_(ids))
if self._supports_soft_delete and not include_deleted:
sql = sql.where(getattr(self.model, "is_deleted") == false())
sql = sql.values(**kwargs)
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"批量更新失败: {e!s}") from e
async def restore(self, ids: list[int]) -> None:
"""反删除:还原 is_deleted、清空删除时间和人。"""
try:
if not self._supports_soft_delete:
raise CustomException(msg="该模型不支持软删除,无法恢复")
pk = self._get_pk_col()
sql = 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 Exception as e:
raise CustomException(msg=f"恢复失败: {e!s}") from e
# ── 条件与排序 ────────────────────────────────────────────────────
async def _build_conditions(self, include_deleted: bool = False, **kwargs) -> list[ColumnElement]:
"""根据 kwargs 动态拼接 WHERE 条件列表。
值类型决定比较方式:
- tuple → 委托 _resolve_conditionlike/in/between/date/null/比较操作符)
- 其他 → 等值比较兜底(仅兼容 ``get(id=1)`` 等直接关键字传参)
None / 空串的键值对跳过,不做条件。
提示:分页/列表/统计查询走 ``search_to_dict`` 后 kwargs 值均为 tuple
由 Schema 层的 ``json_schema_extra={"q": "..."}`` 精确控制操作符。
"""
conditions: list[ColumnElement] = []
# 自动排除已删除记录(除非调用方明确要查询已删除数据)
if hasattr(self.model, "is_deleted") and not include_deleted:
conditions.append(getattr(self.model, "is_deleted") == false())
from app.core.permission import Permission
permission_condition = await Permission(self.model, self.auth, self.db)._permission_condition()
if permission_condition is not None:
conditions.append(permission_condition)
for key, value in kwargs.items():
if value is None or value == "":
continue
attr = getattr(self.model, key)
if isinstance(value, tuple):
conditions.extend(self._resolve_condition(attr, value))
else:
conditions.append(attr == value)
return conditions
@staticmethod
def _resolve_condition(attr: ColumnElement, value: tuple) -> list[ColumnElement]:
"""元组条件 `(seq, val)` → SQLAlchemy condition。
seq 支持:None / not None / date / month / like / in / between / 比较操作符。
date 返回 [>=当天0:00, <第二天0:00) 的范围,
month 返回 [>=1号0:00, <下月1号0:00) 的范围。
"""
seq, val = value
# 先处理不依赖 val 的 IS NULL / IS NOT NULL
handlers: dict[str, Any] = {
"None": lambda: [attr.is_(None)],
"not None": lambda: [attr.isnot(None)],
}
if seq in handlers:
return handlers[seq]()
if val is None:
return []
if seq == "date":
dt = datetime.strptime(val, "%Y-%m-%d")
return [attr >= dt, attr < dt + timedelta(days=1)]
if seq == "month":
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)
return [attr >= dt, attr < next_month]
if seq == "like":
return [attr.like(f"%{val}%")]
if seq == "in":
if isinstance(val, (list, tuple, set)) and len(val) == 0:
return [false()] # 空集合查询 = 永假条件
return [attr.in_(val)]
if seq == "between" and isinstance(val, (list, tuple)) and len(val) == 2:
return [attr.between(val[0], val[1])]
method = _OPERATOR_MAP.get(seq)
if method is not None:
return [getattr(attr, method)(val)]
return []
def _parse_order(self, order: list[dict[str, str]]) -> list[ColumnElement]:
"""`[{"field": "asc|desc"}, ...]` → SQLAlchemy order_by 子句。"""
columns: list[ColumnElement] = []
for item in order:
for field, direction in item.items():
column = getattr(self.model, field) # type: ignore[arg-type]
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]:
"""将字符串预加载描述转为 SQLAlchemy loading options。
- "user" → joinedload(一对一/多对一)或 selectinload(一对多/多对多)
- "user.dept" → 嵌套加载(通过 .options() 链式组合)
- 已存在 options 对象 → 原样追加
自动为 created_by / updated_by 添加 joinedload(所有查询都 LEFT JOIN 用户表获取审计人)。
"""
options: list[Any] = []
if not preload:
preload = []
mapper = sa_inspect(self.model)
processed_attrs = set()
for opt in preload:
if isinstance(opt, str):
parts = opt.split(".")
if len(parts) == 1:
attr_name = parts[0]
if attr_name in processed_attrs:
continue # 跳过同层重复名称
processed_attrs.add(attr_name)
if not hasattr(self.model, attr_name):
continue # 非模型属性,忽略
prop = mapper.relationships.get(attr_name)
if prop is None:
continue # 列属性不支持 eager loading,忽略
attr = getattr(self.model, attr_name)
# 一对一/多对一用 joinedload(一条 SQL 完成),一对多/多对多用 selectinloadN+1 → 2 条 SQL
if not prop.uselist:
options.append(joinedload(attr))
else:
options.append(selectinload(attr))
else:
full_path = ".".join(parts)
if full_path in processed_attrs:
continue # 跳过完全相同的嵌套路径
processed_attrs.add(full_path)
current_model = self.model
current_mapper = mapper
current_option = None
for part in parts:
if not hasattr(current_model, part):
break
attr = getattr(current_model, part)
prop = current_mapper.relationships.get(part)
if prop is None:
break # 非关系属性中断链
loader = selectinload(attr) if prop.uselist else joinedload(attr)
# 嵌套加载通过 .options() 链式组合
if current_option is None:
current_option = loader
else:
current_option = current_option.options(loader)
current_model = prop.mapper.class_
current_mapper = sa_inspect(current_model)
if current_option is not None:
options.append(current_option)
else:
options.append(opt)
# 自动预加载审计关系:常见列表页都要展示创建人/更新人,统一处理避免 N+1
for audit_attr in ["created_by", "updated_by"]:
if audit_attr not in processed_attrs and audit_attr in mapper.relationships:
prop = mapper.relationships[audit_attr]
if hasattr(self.model, audit_attr):
attr = getattr(self.model, audit_attr)
options.append(joinedload(attr))
return options