"""育种审计日志 模块服务层(§3.10;写操作全程可追溯)""" from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.base_schema import AuthSchema, PageResultSchema from app.core.exceptions import CustomException from .crud import BreedingAuditCRUD from .model import AuditLogModel from .schema import AuditLogOutSchema, AuditLogQueryParam # 供 Service 层在导入汇总/业务路径直接落审计行(不触发 CRUDBase 递归审计) _ALLOWED_ACTIONS = {"CREATE", "UPDATE", "DELETE", "IMPORT"} def _fmt(v: Any) -> str | None: if v is None: return None s = str(v) return s[:500] if len(s) > 500 else s class AuditLogService: """审计日志 —— 只读查询 + 静态写入。写入走 CRUDBase 自动注入,不对外暴露增删改。""" def __init__(self, auth: AuthSchema, db: AsyncSession) -> None: self.auth = auth self.db = db @staticmethod async def write( db: AsyncSession, *, entity_type: str, entity_id: int | None, action: str, field_name: str | None = None, old_value: Any = None, new_value: Any = None, created_id: int | None = None, operator_id: int | None = None, ) -> int: """直接落一条审计行(不经 CRUDBase,防递归)。返回新行 id。""" if action not in _ALLOWED_ACTIONS: raise CustomException(msg=f"非法审计操作类型: {action}") row = AuditLogModel( entity_type=entity_type[:32] if entity_type else entity_type, entity_id=entity_id, action=action, field_name=(field_name[:64] if field_name else field_name), old_value=_fmt(old_value), new_value=_fmt(new_value), created_id=created_id, operator_id=operator_id, ) db.add(row) await db.flush() return int(row.id) async def page( self, page_no: int, page_size: int, search: AuditLogQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> PageResultSchema[AuditLogOutSchema]: from app.utils.common_util import search_to_dict result = await BreedingAuditCRUD(self.auth, self.db).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "desc"}], search=search_to_dict(search, {}), out_schema=AuditLogOutSchema, ) await self._attach_labels(result.items) return result async def get_list(self, search: AuditLogQueryParam | None = None) -> list[AuditLogOutSchema]: from app.utils.common_util import search_to_dict objs = await BreedingAuditCRUD(self.auth, self.db).get_list( search=search_to_dict(search, {}), order_by=[{"id": "desc"}] ) outs = [AuditLogOutSchema.model_validate(o) for o in objs] await self._attach_labels(outs) return outs async def detail(self, id: int) -> AuditLogOutSchema: obj = await BreedingAuditCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="该审计日志不存在") out = AuditLogOutSchema.model_validate(obj) await self._attach_labels([out]) return out async def _attach_labels(self, items: list[AuditLogOutSchema]) -> None: op_ids = {getattr(it, "operator_id") for it in items if getattr(it, "operator_id")} if op_ids: from app.api.v1.module_bre.personnel.model import PersonnelModel rows = (await self.db.execute( select(PersonnelModel.id, PersonnelModel.name).where(PersonnelModel.id.in_(op_ids)) )).all() op_map = {r.id: r.name for r in rows} for it in items: it.operator_name = op_map.get(getattr(it, "operator_id")) async def options(self) -> dict[str, Any]: """前端筛选下拉:实体类型 / 操作类型 去重集合。""" etypes = (await self.db.execute( select(AuditLogModel.entity_type) .where(AuditLogModel.entity_type.isnot(None)) .distinct() )).scalars().all() actions = (await self.db.execute( select(AuditLogModel.action) .where(AuditLogModel.action.isnot(None)) .distinct() )).scalars().all() return {"entity_types": sorted(etypes), "actions": sorted(actions)}