from fastapi import HTTPException from fastapi.encoders import jsonable_encoder from sqlalchemy import func, inspect, select from sqlalchemy.orm import Session from app.models import AuditEvent, Document, KnowledgeItem, Source from app.models.catalog import utc_now def get_item(db: Session, model, item_id: int, *, include_deleted=False, lock=False): if lock: item = db.scalar( select(model) .where(model.id == item_id) .with_for_update() .execution_options(populate_existing=True) ) else: item = db.get(model, item_id) if item is None or (item.is_deleted and not include_deleted): raise HTTPException(404, "记录不存在") return item def ensure_version(item, expected_version: int): if item.version != expected_version: raise HTTPException(409, "记录已更新,请刷新后重试") def values(item) -> dict: return jsonable_encoder( {column.key: getattr(item, column.key) for column in inspect(type(item)).columns} ) def record_event(db: Session, item, action: str, *, actor=None, notes=""): db.flush() snapshot = {"item": values(item)} if isinstance(item, KnowledgeItem): source = db.get(Source, item.source_id) if item.source_id else None document = db.get(Document, item.document_id) if item.document_id else None snapshot["source"] = values(source) if source else None snapshot["document"] = values(document) if document else None db.add( AuditEvent( entity_type=item.__tablename__, entity_id=item.id, action=action, actor=actor, notes=notes, snapshot=snapshot, ) ) def update_values(item, payload): data = payload.model_dump(mode="json", exclude_unset=True, exclude={"expected_version"}) data.update( { key: value for key, value in payload.model_dump(exclude_unset=True).items() if key.endswith("_at") } ) for name, value in data.items(): setattr(item, name, value) item.updated_at = utc_now() def reset_review(item: KnowledgeItem): item.verification_status = "pending" item.reviewer = None item.reviewed_at = None item.verification_notes = None item.updated_at = utc_now() def invalidate_knowledge(db: Session, condition, *, source_url=None, update_url=False): for item in db.scalars( select(KnowledgeItem).where(condition, KnowledgeItem.is_deleted.is_(False)) ): reset_review(item) if update_url: item.source_url = source_url record_event(db, item, "invalidate", notes="来源或文档已变更,需要重新核验") def resolve_knowledge_links(db: Session, item: KnowledgeItem): # 先锁依据记录再保存条目,避免引用与父记录修改/删除同时发生。 with db.no_autoflush: if item.document_id is not None: document = get_item(db, Document, item.document_id, lock=True) if item.source_id is not None and item.source_id != document.source_id: raise HTTPException(422, "文档与来源不一致") item.source_id = document.source_id if item.source_id is not None: source = get_item(db, Source, item.source_id, lock=True) if item.source_url and item.source_url != source.source_url: raise HTTPException(422, "来源网址与登记来源不一致") item.source_url = source.source_url def page_result(db: Session, statement, page: int, page_size: int): total = db.scalar(select(func.count()).select_from(statement.order_by(None).subquery())) return { "items": list(db.scalars(statement.offset((page - 1) * page_size).limit(page_size))), "total": total, "page": page, "page_size": page_size, } def history(db: Session, model, item_id: int, page: int, page_size: int): get_item(db, model, item_id, include_deleted=True) statement = ( select(AuditEvent) .where(AuditEvent.entity_type == model.__tablename__, AuditEvent.entity_id == item_id) .order_by(AuditEvent.id.desc()) ) return page_result(db, statement, page, page_size)