from datetime import datetime from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from backend.app.core.config import get_settings from backend.app.core.error_codes import ErrorCode from backend.app.core.exceptions import AppException from backend.app.repositories.file_repository import FileRepository from backend.app.services.audit_service import audit_service from backend.app.services.storage_service import storage_service class FileService: def __init__(self) -> None: self.settings = get_settings() self.repository = FileRepository() def create_upload_token(self, payload: dict, session: Session | None = None) -> dict: if not payload["file_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="文件名不能为空", status_code=400) if not payload["biz_type"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400) file_category = self._detect_file_category(payload["file_type"], payload["file_name"]) self._validate_file_size(file_category, payload["file_size"]) token_payload = storage_service.create_upload_token( payload["biz_type"], payload["biz_id"], payload["file_name"], payload["file_size"], ) token_payload["file_category"] = file_category return token_payload def save_attachment(self, payload: dict, session: Session | None = None) -> dict: if session is None: raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500) attachment_payload = self.build_attachment_payload(payload, created_by=payload.get("created_by")) try: attachment = self.repository.create_attachment(session, attachment_payload) audit_service.write_log( session, { "operate_type": "attachment_create", "biz_type": payload["biz_type"], "biz_id": payload["biz_id"], "before_value": None, "after_value": self._map_attachment(attachment), "remark": f"保存附件 {payload['file_name']}", }, ) session.commit() return self._map_attachment(attachment) except AppException: session.rollback() raise except SQLAlchemyError as exc: session.rollback() raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc def build_attachment_payload( self, payload: dict, created_by: int | None = None, require_file_size: bool = True, ) -> dict: if not payload["biz_type"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400) if not payload["file_name"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="文件名不能为空", status_code=400) if not payload["file_url"].strip(): raise AppException(code=ErrorCode.PARAM_ERROR, message="文件地址不能为空", status_code=400) file_name = payload["file_name"] file_url = payload["file_url"] file_type = payload.get("file_type") or None file_size = payload.get("file_size") if require_file_size and (file_size is None or int(file_size) <= 0): raise AppException(code=ErrorCode.PARAM_ERROR, message="文件大小不能为空", status_code=400) file_category = self._detect_file_category(file_type or "", file_name) if file_size is not None and int(file_size) > 0: self._validate_file_size(file_category, int(file_size)) object_key = storage_service.extract_object_key(file_url) file_ext = self._extract_file_ext(file_name) self._validate_attachment_file(file_name, file_url, object_key) # 司机任务操作里可能只有文件名和 URL,这里允许 MIME 和大小为空,但仍统一沉淀附件元数据。 return { "biz_type": payload["biz_type"], "biz_id": payload["biz_id"], "file_name": file_name, "file_url": file_url, "file_type": file_type, "file_size": int(file_size) if file_size is not None else None, "created_by": created_by, "storage_provider": "aliyun_oss", "bucket_name": self.settings.aliyun_oss_bucket, "object_key": object_key, "content_type": file_type, "file_ext": file_ext, "file_category": file_category, } def _validate_attachment_file(self, file_name: str, file_url: str, object_key: str) -> None: expected_name = file_name.strip() object_name = object_key.rsplit("/", 1)[-1] # 保存附件时再次核对 object key 与文件名,避免前端绕过上传凭证直接塞入不匹配文件。 if expected_name and expected_name not in object_name: raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件文件名与对象地址不匹配", status_code=400) if not file_url.startswith(("http://", "https://")): raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件地址格式不正确", status_code=400) def _detect_file_category(self, file_type: str, file_name: str) -> str: content_type = (file_type or "").lower() suffix = self._extract_file_ext(file_name).lower() if content_type.startswith("image/") or suffix in {"jpg", "jpeg", "png", "gif", "webp"}: return "image" if content_type.startswith("video/") or suffix in {"mp4", "mov", "avi"}: return "video" if suffix in {"xlsx", "xls", "csv"}: return "import" return "document" def _validate_file_size(self, file_category: str, file_size: int) -> None: max_mb = ( self.settings.oss_upload_max_image_mb if file_category == "image" else self.settings.oss_upload_max_video_mb if file_category == "video" else max(self.settings.oss_upload_max_image_mb, self.settings.oss_upload_max_video_mb) ) if file_size > max_mb * 1024 * 1024: raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="文件大小超出限制", status_code=400) def _extract_file_ext(self, file_name: str) -> str: parts = file_name.rsplit(".", 1) return parts[1] if len(parts) == 2 else "" def _map_attachment(self, attachment) -> dict: return { "attachment_id": attachment.id, "biz_type": attachment.biz_type, "biz_id": attachment.biz_id, "file_name": attachment.file_name, "file_url": attachment.file_url, "file_type": attachment.file_type, "file_size": attachment.file_size, "storage_provider": attachment.storage_provider, "bucket_name": attachment.bucket_name, "object_key": attachment.object_key, "content_type": attachment.content_type, "file_ext": attachment.file_ext, "file_category": attachment.file_category, "created_at": attachment.created_at.strftime("%Y-%m-%d %H:%M:%S") if attachment.created_at else "", } file_service = FileService()