Files
2026-06-30 17:10:51 +08:00

171 lines
6.1 KiB
Python

from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from fastapi import HTTPException, UploadFile, status
from app.config import settings
from app.enums.home_material import (
HOME_MATERIAL_IMAGE_EXTENSIONS,
HOME_MATERIAL_VIDEO_EXTENSIONS,
HOME_MATERIAL_WATERMARK_EXTENSIONS,
HomeMaterialMediaType,
)
from app.utils.id_gen import generate_id
@dataclass(frozen=True)
class StoredFile:
storage_path: str
file_url: str
file_name: str
file_size_bytes: int
suffix: str
class HomeMaterialStorageService:
"""首页素材文件存储服务。只处理文件、目录、URL,不访问数据库。"""
def __init__(self) -> None:
self.upload_root = Path(settings.UPLOAD_LOCAL_PATH).resolve()
self.home_root = self.upload_root / "home_materials"
def _date_dir(self) -> str:
return datetime.now().strftime("%Y/%m/%d")
def _safe_suffix(self, filename: str | None) -> str:
return Path(filename or "").suffix.lower()
def _assert_extension(self, suffix: str, allowed: Iterable[str], label: str) -> None:
if suffix not in set(allowed):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{label}文件类型不支持:{suffix or '无扩展名'}",
)
def _limit_mb(self, media_type: HomeMaterialMediaType | str) -> int:
if media_type == HomeMaterialMediaType.VIDEO or str(media_type) == HomeMaterialMediaType.VIDEO.value:
return int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_MB", 300))
return int(getattr(settings, "HOME_MATERIAL_MAX_IMAGE_MB", 20))
def _build_target(self, relative_dir: str, suffix: str) -> tuple[Path, str]:
file_id = generate_id()
relative = Path(relative_dir) / self._date_dir() / f"{file_id}{suffix}"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return path, f"/uploads/home_materials/{relative.as_posix()}"
async def save_upload_file(
self,
file: UploadFile,
*,
relative_dir: str,
allowed_extensions: Iterable[str],
label: str,
max_mb: int | None = None,
) -> StoredFile:
suffix = self._safe_suffix(file.filename)
self._assert_extension(suffix, allowed_extensions, label)
target_path, file_url = self._build_target(relative_dir, suffix)
max_bytes = int(max_mb or 0) * 1024 * 1024 if max_mb else None
tmp_path = target_path.with_name(target_path.name + ".part")
size = 0
try:
with tmp_path.open("wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if max_bytes and size > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{label}文件大小超限,最大 {max_mb}MB",
)
out.write(chunk)
os.replace(tmp_path, target_path)
except Exception:
try:
if tmp_path.exists():
tmp_path.unlink()
finally:
raise
finally:
await file.seek(0)
return StoredFile(
storage_path=str(target_path),
file_url=file_url,
file_name=file.filename or target_path.name,
file_size_bytes=size,
suffix=suffix,
)
async def save_asset_file(self, file: UploadFile, media_type: HomeMaterialMediaType | str) -> StoredFile:
media = HomeMaterialMediaType(media_type)
if media == HomeMaterialMediaType.IMAGE:
return await self.save_upload_file(
file,
relative_dir="original/images",
allowed_extensions=HOME_MATERIAL_IMAGE_EXTENSIONS,
label="图片素材",
max_mb=self._limit_mb(media),
)
return await self.save_upload_file(
file,
relative_dir="original/videos",
allowed_extensions=HOME_MATERIAL_VIDEO_EXTENSIONS,
label="视频素材",
max_mb=self._limit_mb(media),
)
async def save_watermark_file(self, file: UploadFile) -> StoredFile:
return await self.save_upload_file(
file,
relative_dir="watermarks",
allowed_extensions=HOME_MATERIAL_WATERMARK_EXTENSIONS,
label="水印图片",
max_mb=int(getattr(settings, "HOME_MATERIAL_MAX_WATERMARK_MB", 10)),
)
def build_watermarked_target(self, asset_id: str, media_type: HomeMaterialMediaType | str) -> tuple[str, str]:
media = HomeMaterialMediaType(media_type)
suffix = ".png" if media == HomeMaterialMediaType.IMAGE else ".mp4"
folder = "watermarked/images" if media == HomeMaterialMediaType.IMAGE else "watermarked/videos"
relative = Path(folder) / self._date_dir() / f"{asset_id}{suffix}"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
def build_cover_target(self, asset_id: str) -> tuple[str, str]:
relative = Path("covers") / self._date_dir() / f"{asset_id}.jpg"
path = self.home_root / relative
path.parent.mkdir(parents=True, exist_ok=True)
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
@staticmethod
def file_size(path: str | None) -> int | None:
if not path:
return None
try:
return Path(path).stat().st_size
except FileNotFoundError:
return None
@staticmethod
def safe_remove(path: str | None) -> None:
if not path:
return
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
storage_service = HomeMaterialStorageService()