709 lines
32 KiB
Python
709 lines
32 KiB
Python
from collections.abc import Sequence
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from typing import Any, TypeVar, cast
|
||
|
||
from fastapi import status
|
||
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import asc, delete, desc, false, func, select, true, update
|
||
from sqlalchemy import inspect as sa_inspect
|
||
from sqlalchemy.exc import IntegrityError
|
||
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)
|
||
|
||
|
||
def _audit_value_eq(a: Any, b: Any) -> bool:
|
||
"""审计 diff 值比较:datetime/date 统一按字符串比较,规避 aware/naive 时区噪声。"""
|
||
if isinstance(a, (datetime, date)) or isinstance(b, (datetime, date)):
|
||
return str(a) == str(b)
|
||
return a == b
|
||
|
||
|
||
async def assert_parents_exist(
|
||
db: AsyncSession,
|
||
refs: list[tuple[type[ModelMixin], Any, str]],
|
||
) -> None:
|
||
"""create/update 前置校验:逐一确认 FK 父记录存在且未软删。
|
||
|
||
refs: [(Model, fk_value, 中文名), ...];fk_value 为 None 时跳过(未提交该关联)。
|
||
软删除模式下 DB 外键只挡物理删除,父记录被软删后仍能引用 —— 这里补业务级拦截。
|
||
"""
|
||
for model, value, label in refs:
|
||
if value is None:
|
||
continue
|
||
conditions = [getattr(model, "id") == value]
|
||
if hasattr(model, "is_deleted"):
|
||
conditions.append(getattr(model, "is_deleted") == false())
|
||
result = await db.execute(select(func.count()).select_from(model).where(*conditions))
|
||
if not (result.scalar() or 0):
|
||
raise CustomException(msg=f"{label}不存在或已被删除", status_code=status.HTTP_409_CONFLICT)
|
||
|
||
|
||
async def assert_no_children(
|
||
db: AsyncSession,
|
||
ids: list[int],
|
||
children: list[tuple[type[ModelMixin], str, str]],
|
||
) -> None:
|
||
"""delete 前置校验:逐一统计下游子表引用数,存在活数据则拒绝删除。
|
||
|
||
children: [(ChildModel, fk_col, 中文名), ...]。只统计未软删的子记录 ——
|
||
已进回收站的子数据不阻塞父级删除,与回收站语义一致。
|
||
"""
|
||
if not ids:
|
||
return
|
||
for model, fk_col, label in children:
|
||
conditions = [getattr(model, fk_col).in_(ids)]
|
||
if hasattr(model, "is_deleted"):
|
||
conditions.append(getattr(model, "is_deleted") == false())
|
||
result = await db.execute(select(func.count()).select_from(model).where(*conditions))
|
||
n = result.scalar() or 0
|
||
if n:
|
||
raise CustomException(
|
||
msg=f"删除失败:{label}下存在 {n} 条关联数据,请先处理",
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
)
|
||
|
||
|
||
# 选育阶段链(dict_value 顺序)。tree.status / selection_result.is_selected 共用:
|
||
# 阶段只能向前流转(存活→入选→初选→重点→保存→淘汰),禁止回退。
|
||
STAGE_CHAINS: dict[str, list[str]] = {
|
||
"tree_status": ["alive", "selected", "primary", "key", "preserved", "eliminated"],
|
||
"identify_status": ["unknown", "selected", "primary", "key", "preserved", "eliminated"],
|
||
}
|
||
|
||
|
||
async def assert_status_forward(
|
||
db: AsyncSession,
|
||
dict_type: str,
|
||
existing_values: list[Any],
|
||
new_value: Any,
|
||
label: str,
|
||
) -> None:
|
||
"""选育阶段状态机:新阶段不得回退到该对象此前已到达的最晚阶段之前。
|
||
|
||
existing_values: 该对象此前已记录的所有阶段值(可空列表,顺序无关)。
|
||
new_value 为空视为不推进阶段(跳过);同值/前进放行;不在阶段链内的取值
|
||
(历史自由文本)交由 assert_dict_values 统一校验,此处不拦。
|
||
"""
|
||
if new_value is None or new_value == "":
|
||
return
|
||
chain = STAGE_CHAINS.get(dict_type)
|
||
if not chain or new_value not in chain:
|
||
return
|
||
max_idx = -1
|
||
for v in existing_values:
|
||
if v and str(v) in chain:
|
||
max_idx = max(max_idx, chain.index(str(v)))
|
||
if max_idx >= 0 and chain.index(new_value) < max_idx:
|
||
raise CustomException(
|
||
msg=f"{label}不可回退:该对象已流转至更靠后的选育阶段",
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
)
|
||
|
||
|
||
async def assert_dict_values(
|
||
db: AsyncSession,
|
||
values: list[tuple[str, Any, str]],
|
||
) -> None:
|
||
"""create/update 前置校验:枚举字段值必须存在于对应字典类型的 dict_value 中。
|
||
|
||
values: [(dict_type, 值, 中文名), ...];值为 None/空串时跳过(可空枚举字段)。
|
||
统一以 sys_dict_data 为权威(与前端下拉、导入 Resolver 同源),杜绝直接 API
|
||
绕过字典写入中文或未知编码。惰性导入避免与 module_system 循环依赖。
|
||
"""
|
||
from app.api.v1.module_system.dict.model import DictDataModel
|
||
|
||
for dict_type, value, label in values:
|
||
if value is None or value == "":
|
||
continue
|
||
conditions = [
|
||
DictDataModel.dict_type == dict_type,
|
||
DictDataModel.dict_value == str(value),
|
||
]
|
||
if hasattr(DictDataModel, "is_deleted"):
|
||
conditions.append(DictDataModel.is_deleted == false())
|
||
result = await db.execute(select(func.count()).select_from(DictDataModel).where(*conditions))
|
||
if not (result.scalar() or 0):
|
||
raise CustomException(
|
||
msg=f"{label}取值非法,请使用字典中定义的选项({value})",
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
)
|
||
|
||
# 操作符 → 方法名映射,留给 _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
|
||
|
||
# ── L0 审计切面(bre_audit_log,§3.10)───────────────────────────────
|
||
# 目标:对 module_bre 业务表(bre_*)写操作落审计日志。接入点=下方三个写方法,
|
||
# 事务内 flush 后追加审计行,随业务原子提交。开关=settings.AUDIT_BRE_ENABLED +
|
||
# 会话抑制标志(批量导入路径抑制逐行审计,仅汇总记一条)。
|
||
|
||
async def _bre_audit_enabled(self) -> bool:
|
||
tname = getattr(self.model, "__tablename__", "") or ""
|
||
if not tname.startswith("bre_") or tname == "bre_audit_log":
|
||
return False
|
||
from app.config.setting import settings
|
||
|
||
if not getattr(settings, "AUDIT_BRE_ENABLED", True):
|
||
return False
|
||
from app.core.bre_audit_ctx import bre_audit_suppressed
|
||
|
||
if bre_audit_suppressed():
|
||
return False
|
||
return True
|
||
|
||
async def _bre_audit_operator_id(self) -> int | None:
|
||
"""操作人→bre_personnel:仅当育种人员姓名==操作用户名命中时填(默认只填 created_id)。"""
|
||
username = getattr(getattr(self.auth, "user", None), "username", None)
|
||
if not username:
|
||
return None
|
||
from app.api.v1.module_bre.personnel.model import PersonnelModel
|
||
|
||
return await self.db.scalar(
|
||
select(PersonnelModel.id)
|
||
.where(
|
||
PersonnelModel.name == username,
|
||
PersonnelModel.is_deleted.is_(False),
|
||
)
|
||
.limit(1)
|
||
)
|
||
|
||
async def _bre_audit_write(
|
||
self,
|
||
*,
|
||
entity_id: int | None,
|
||
action: str,
|
||
field_name: str | None = None,
|
||
old_value: Any = None,
|
||
new_value: Any = None,
|
||
operator_id: int | None = None,
|
||
) -> None:
|
||
from app.api.v1.module_bre.audit.service import AuditLogService
|
||
|
||
await AuditLogService.write(
|
||
self.db,
|
||
entity_type=self.model.__tablename__,
|
||
entity_id=entity_id,
|
||
action=action,
|
||
field_name=field_name,
|
||
old_value=old_value,
|
||
new_value=new_value,
|
||
created_id=self.auth.user.id if self.auth.user.id else None,
|
||
operator_id=operator_id,
|
||
)
|
||
|
||
# ── 查询 ──────────────────────────────────────────────────────────
|
||
|
||
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
|
||
|
||
# ── 写入 ──────────────────────────────────────────────────────────
|
||
|
||
# ── L0 审计切面设计(波次 7.3,可行性已确认;实现时按此接入 bre_audit_log)──
|
||
# 目标:对 module_bre 业务表写操作落审计日志。bre_audit_log 已建表(16 列,
|
||
# 含 entity_type/entity_id/action/field_name/old_value/new_value/operator_id)。
|
||
#
|
||
# 接入点:下方 3 个写方法,事务内 flush 之后追加审计行,随业务原子提交。
|
||
# create -> action='CREATE',记录级(entity_type=表名, entity_id=obj.id)
|
||
# update -> obj 在改值前已 get(),可零额外查询做字段级 diff:action='UPDATE',
|
||
# 每个变更字段一行(field_name/old_value/new_value)
|
||
# delete -> 软删除是批量 UPDATE 未载入旧值,记录级 action='DELETE'(列 entity_id);
|
||
# 需字段级则先 SELECT 载入,成本高,默认不做
|
||
#
|
||
# 关键约束:
|
||
# - bre_audit_log 的 uuid/created_time/updated_time/is_deleted NOT NULL 且无默认值,
|
||
# 写入须显式填 uuid=str(uuid4())、created_time/updated_time=now()、is_deleted=false
|
||
# - operator_id→bre_personnel 与 created_id→sys_user 并存;bre_personnel 无 user_id
|
||
# 关联,默认只填 created_id=user.id;operator_id 仅在 personnel.name==user.username
|
||
# 命中时填(可后续加 personnel.user_id 打通)
|
||
# - 开关:settings 加 AUDIT_BRE_ENABLED(默认 True)或模型 opt-in(__bre_audit__=True);
|
||
# 批量导入路径在 service 层用会话标志抑制逐行审计,仅汇总记一条
|
||
# - 实体映射:entity_type 用 self.model.__tablename__,避免实体名漂移
|
||
|
||
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)
|
||
|
||
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()
|
||
|
||
if await self._bre_audit_enabled():
|
||
op = await self._bre_audit_operator_id()
|
||
await self._bre_audit_write(entity_id=obj.id, action="CREATE", operator_id=op)
|
||
|
||
return obj
|
||
except IntegrityError:
|
||
# 让 IntegrityError 冒泡到全局处理器(409 + 友好文案),不被吞成 500
|
||
raise
|
||
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)
|
||
|
||
# 审计字段级 diff:先快照旧值(obj 改值前已 get(),零额外查询)
|
||
before = {key: getattr(obj, key) for key in obj_dict if hasattr(obj, key)}
|
||
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()
|
||
|
||
if await self._bre_audit_enabled():
|
||
op = await self._bre_audit_operator_id()
|
||
for key in obj_dict:
|
||
if not hasattr(obj, key):
|
||
continue
|
||
new = getattr(obj, key)
|
||
old = before.get(key)
|
||
if _audit_value_eq(old, new):
|
||
continue
|
||
await self._bre_audit_write(
|
||
entity_id=obj.id, action="UPDATE", field_name=key,
|
||
old_value=old, new_value=new, operator_id=op,
|
||
)
|
||
|
||
return obj
|
||
except CustomException:
|
||
raise
|
||
except IntegrityError:
|
||
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()
|
||
|
||
if await self._bre_audit_enabled():
|
||
op = await self._bre_audit_operator_id()
|
||
for id_ in ids:
|
||
await self._bre_audit_write(entity_id=id_, action="DELETE", operator_id=op)
|
||
except IntegrityError:
|
||
raise
|
||
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 IntegrityError:
|
||
raise
|
||
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 IntegrityError:
|
||
raise
|
||
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 IntegrityError:
|
||
raise
|
||
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_condition(like/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 完成),一对多/多对多用 selectinload(N+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
|