Files
ai-contract-platform/backend/app/services/file_service.py
T
2026-05-03 12:28:38 +08:00

268 lines
9.3 KiB
Python

from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
import hashlib
import io
from fastapi import HTTPException, UploadFile, status
from minio import Minio
from minio.error import S3Error
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.file import File
from app.repositories.file_repository import FileRepository
class FileService:
def __init__(self, db: Session):
self.db = db
self.repository = FileRepository(db)
self.minio_client = Minio(
settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_secure,
)
def list_files(
self,
current_user: Dict[str, Any],
page: int = 1,
page_size: int = 20,
file_type: Optional[str] = None,
) -> dict:
self._require_admin(current_user)
tenant_id = current_user["tenant_id"]
normalized_page = max(page, 1)
normalized_page_size = max(min(page_size, 100), 1)
if file_type:
files = self.repository.list_by_type(
tenant_id,
file_type,
normalized_page,
normalized_page_size,
)
total = self.repository.count(tenant_id, file_type)
else:
files = self.repository.list_paginated(
tenant_id,
normalized_page,
normalized_page_size,
)
total = self.repository.count(tenant_id)
return {
"items": [self._to_item(file) for file in files],
"page": normalized_page,
"page_size": normalized_page_size,
"total": total,
}
def upload_file(
self,
current_user: Dict[str, Any],
file: UploadFile,
file_type: str,
bucket: str = "default",
) -> dict:
self._require_admin(current_user)
tenant_id = current_user["tenant_id"]
content = file.file.read()
file_size = len(content)
file_hash = hashlib.sha256(content).hexdigest()
file_id = f"file-{uuid4().hex[:12]}"
safe_file_name = Path(file.filename or "uploaded-file").name
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
storage_bucket = self._resolve_storage_bucket(bucket)
self._ensure_bucket(storage_bucket)
self._put_object(storage_bucket, object_key, content, file.content_type or "application/octet-stream")
file_record = File(
id=file_id,
tenant_id=tenant_id,
uploaded_by=current_user["id"],
bucket=storage_bucket,
object_key=object_key,
file_name=safe_file_name,
mime_type=file.content_type or "application/octet-stream",
file_size=file_size,
file_hash=file_hash,
file_type=file_type,
status="active",
)
try:
created = self.repository.create(file_record)
except Exception:
self._remove_object_if_exists(storage_bucket, object_key)
raise
return self._to_item(created)
def create_file_record(
self,
current_user: Dict[str, Any],
file_name: str,
content: bytes,
file_type: str,
mime_type: str,
bucket: str = "generated",
require_admin: bool = True,
) -> dict:
if require_admin:
self._require_admin(current_user)
tenant_id = current_user["tenant_id"]
file_id = f"file-{uuid4().hex[:12]}"
safe_file_name = Path(file_name).name or "generated-file"
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
storage_bucket = self._resolve_storage_bucket(bucket)
self._ensure_bucket(storage_bucket)
self._put_object(storage_bucket, object_key, content, mime_type)
file_record = File(
id=file_id,
tenant_id=tenant_id,
uploaded_by=current_user["id"],
bucket=storage_bucket,
object_key=object_key,
file_name=safe_file_name,
mime_type=mime_type,
file_size=len(content),
file_hash=hashlib.sha256(content).hexdigest(),
file_type=file_type,
status="active",
)
try:
created = self.repository.create(file_record)
except Exception:
self._remove_object_if_exists(storage_bucket, object_key)
raise
return self._to_item(created)
def build_upload_file(self, file_name: str, content: bytes, mime_type: str) -> UploadFile:
upload = UploadFile(filename=file_name, file=io.BytesIO(content))
upload.headers = upload.headers.__class__({"content-type": mime_type})
return upload
def get_file(self, current_user: Dict[str, Any], file_id: str) -> dict:
return self._to_item(self._get_active_file(current_user, file_id))
def delete_file(self, current_user: Dict[str, Any], file_id: str) -> bool:
file_record = self._get_active_file(current_user, file_id)
self._remove_object_if_exists(file_record.bucket, file_record.object_key)
self.repository.soft_delete(file_record)
return True
def get_download_url(self, current_user: Dict[str, Any], file_id: str) -> dict:
file_record = self._get_active_file(current_user, file_id, allow_owner_access=True)
return {
"id": file_record.id,
"file_name": file_record.file_name,
"download_url": f"/api/v1/files/{file_id}/download",
"expires_in": 3600,
}
def get_download_payload(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> dict:
file_record = self._get_active_file(current_user, file_id, allow_owner_access=allow_owner_access)
content = self._read_object(file_record.bucket, file_record.object_key)
return {
"id": file_record.id,
"file_name": file_record.file_name,
"mime_type": file_record.mime_type,
"content": content,
}
def _put_object(self, bucket: str, object_key: str, content: bytes, content_type: str):
self.minio_client.put_object(
bucket_name=bucket,
object_name=object_key,
data=io.BytesIO(content),
length=len(content),
content_type=content_type,
)
def _read_object(self, bucket: str, object_key: str) -> bytes:
response = None
try:
response = self.minio_client.get_object(bucket, object_key)
return response.read()
except S3Error as exc:
if exc.code == "NoSuchKey":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="file content not found",
)
raise
finally:
if response is not None:
response.close()
response.release_conn()
def _remove_object_if_exists(self, bucket: str, object_key: str):
try:
self.minio_client.remove_object(bucket, object_key)
except S3Error as exc:
if exc.code != "NoSuchKey":
raise
def _resolve_storage_bucket(self, bucket: str) -> str:
normalized_bucket = (bucket or "").strip().lower()
if normalized_bucket:
return normalized_bucket
return settings.minio_bucket.strip().lower()
def _ensure_bucket(self, bucket: str):
try:
if not self.minio_client.bucket_exists(bucket):
self.minio_client.make_bucket(bucket)
except S3Error as exc:
if exc.code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
raise
def _require_admin(self, current_user: Dict[str, Any]):
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
def _get_active_file(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> File:
tenant_id = current_user["tenant_id"]
file_record = self.repository.get_by_id(file_id, tenant_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="file not found",
)
if current_user.get("role") == "admin":
return file_record
if allow_owner_access and file_record.uploaded_by == current_user.get("id"):
return file_record
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return file_record
def _to_item(self, file: File) -> dict:
return {
"id": file.id,
"tenant_id": file.tenant_id,
"uploaded_by": file.uploaded_by,
"bucket": file.bucket,
"object_key": file.object_key,
"file_name": file.file_name,
"mime_type": file.mime_type,
"file_size": file.file_size,
"file_hash": file.file_hash,
"file_type": file.file_type,
"status": file.status,
"created_at": file.created_at.isoformat(),
}