from __future__ import annotations import logging from pathlib import Path from uuid import uuid4 from django.conf import settings from django.db import transaction from django.utils.text import get_valid_filename from review_agent.models import Conversation, FileAttachment from .constants import ATTACHMENT_ROOT logger = logging.getLogger("review_agent.file_summary.storage") def _safe_original_name(name: str) -> str: clean = get_valid_filename(Path(name).name) return clean or f"upload-{uuid4().hex}" def _relative_attachment_path(conversation: Conversation, filename: str, version_no: int) -> Path: suffix = Path(filename).suffix stem = Path(filename).stem stored_name = f"{stem}_v{version_no}_{uuid4().hex[:8]}{suffix}" return ( ATTACHMENT_ROOT / str(conversation.user_id) / str(conversation.pk) / "attachments" / stored_name ) def _ensure_inside_media_root(path: Path) -> None: media_root = Path(settings.MEDIA_ROOT).resolve() resolved = path.resolve() if media_root != resolved and media_root not in resolved.parents: raise ValueError("上传路径必须位于 MEDIA_ROOT 内。") @transaction.atomic def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) -> FileAttachment: """Stores an uploaded file and creates a versioned attachment record.""" original_name = _safe_original_name(uploaded_file.name) logger.info( "Attachment upload save started", extra={ "conversation_id": conversation.pk, "user_id": user.pk, "original_name": original_name, "file_size": uploaded_file.size, "content_type": getattr(uploaded_file, "content_type", "") or "", }, ) latest = ( FileAttachment.objects.filter(conversation=conversation, original_name=original_name) .order_by("-version_no") .first() ) version_no = (latest.version_no if latest else 0) + 1 relative_path = _relative_attachment_path(conversation, original_name, version_no) absolute_path = Path(settings.MEDIA_ROOT) / relative_path _ensure_inside_media_root(absolute_path) absolute_path.parent.mkdir(parents=True, exist_ok=True) with absolute_path.open("wb") as target: for chunk in uploaded_file.chunks(): target.write(chunk) FileAttachment.objects.filter( conversation=conversation, original_name=original_name, is_active=True, ).update(is_active=False) attachment = FileAttachment.objects.create( conversation=conversation, user=user, original_name=original_name, version_no=version_no, is_active=True, storage_path=relative_path.as_posix(), file_size=uploaded_file.size, content_type=getattr(uploaded_file, "content_type", "") or "", ) logger.info( "Attachment upload save finished", extra={ "conversation_id": conversation.pk, "attachment_id": attachment.pk, "version_no": attachment.version_no, "storage_path": attachment.storage_path, }, ) return attachment def serialize_attachment(attachment: FileAttachment) -> dict[str, object]: return { "id": attachment.pk, "original_name": attachment.original_name, "version_no": attachment.version_no, "is_active": attachment.is_active, "file_size": attachment.file_size, "content_type": attachment.content_type, "upload_status": attachment.upload_status, "created_at": attachment.created_at.isoformat(), }