管理后台上传集成API | 首页素材装修追加提词/附件
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from app.services.admin_upload.service import admin_upload_service
|
||||
|
||||
__all__ = ["admin_upload_service"]
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.admin_upload import AdminUploadLogEventEnum, AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceDurationSourceEnum,
|
||||
UploadResourceModuleEnum,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_upload import AdminUploadFileOut, AdminUploadMediaReferenceOut
|
||||
from app.services.admin_upload.storage import save_upload_file
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
|
||||
|
||||
def _duration(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
return round(v, 3) if v >= 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _module_for_scene(scene: AdminUploadSceneEnum) -> str:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
return UploadResourceModuleEnum.HOME_MATERIAL.value
|
||||
return UploadResourceModuleEnum.ADMIN_UPLOAD.value
|
||||
|
||||
|
||||
def _role_for_type(resource_type: AdminUploadResourceTypeEnum) -> str | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "reference_image"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "reference_video"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "reference_audio"
|
||||
return None
|
||||
|
||||
|
||||
def _media_reference(*, url: str, resource_type: AdminUploadResourceTypeEnum, name: str | None, duration_seconds: float | None, resource_id: str) -> AdminUploadMediaReferenceOut:
|
||||
return AdminUploadMediaReferenceOut(
|
||||
url=url,
|
||||
type=resource_type.value,
|
||||
name=name,
|
||||
duration=duration_seconds,
|
||||
source="admin_upload",
|
||||
upload_resource_id=resource_id,
|
||||
display_url=url,
|
||||
preview_url=url,
|
||||
role=_role_for_type(resource_type),
|
||||
)
|
||||
|
||||
|
||||
def _log(event_type: str, *, admin: User, scene: str, resource_type: str, status: str = LogEventStatusEnum.SUCCESS.value, message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None) -> None:
|
||||
log_operation_event(
|
||||
domain="admin_upload",
|
||||
module="admin_upload",
|
||||
event_type=event_type,
|
||||
event_status=status,
|
||||
source=LogSourceEnum.API.value,
|
||||
user_id=admin.id,
|
||||
message=message,
|
||||
detail={"scene": scene, "resource_type": resource_type, **(detail or {})},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
class AdminUploadService:
|
||||
async def upload_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
scene: AdminUploadSceneEnum,
|
||||
resource_type: AdminUploadResourceTypeEnum,
|
||||
admin: User,
|
||||
duration_seconds: float | None = None,
|
||||
) -> AdminUploadFileOut:
|
||||
duration = _duration(duration_seconds)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename, "content_type": file.content_type},
|
||||
)
|
||||
stored = None
|
||||
try:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"filename": file.filename, "duration_seconds": duration},
|
||||
)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename},
|
||||
)
|
||||
stored = await save_upload_file(file, scene=scene, resource_type=resource_type, admin_id=admin.id)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"url": stored.file_url, "storage_path": stored.storage_path, "file_size_bytes": stored.file_size_bytes},
|
||||
)
|
||||
module = _module_for_scene(scene)
|
||||
resource = await record_external_upload_resource(
|
||||
db,
|
||||
user_id=admin.id,
|
||||
module=module,
|
||||
resource_type=resource_type.value,
|
||||
resource_url=stored.file_url,
|
||||
storage_path=stored.storage_path,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
file_name=stored.file_name,
|
||||
mime_type=file.content_type,
|
||||
duration_seconds=duration,
|
||||
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if duration is not None else None,
|
||||
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.API.value,
|
||||
metadata={
|
||||
"scene": scene.value,
|
||||
"original_filename": stored.original_file_name,
|
||||
"client_duration_seconds": duration_seconds,
|
||||
"source": "admin_upload",
|
||||
},
|
||||
)
|
||||
await db.refresh(resource)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.DB_RECORD_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"resource_id": resource.id, "url": stored.file_url, "module": module},
|
||||
)
|
||||
media_reference = _media_reference(
|
||||
url=stored.file_url,
|
||||
resource_type=resource_type,
|
||||
name=stored.original_file_name or stored.file_name,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
return AdminUploadFileOut(
|
||||
resource_id=resource.id,
|
||||
scene=scene,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
url=stored.file_url,
|
||||
file_name=stored.file_name,
|
||||
original_file_name=stored.original_file_name,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
media_reference=media_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.FAILED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.FAILED.value,
|
||||
detail=build_exception_detail(exc, {"filename": file.filename}),
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
admin_upload_service = AdminUploadService()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.admin_upload import (
|
||||
ADMIN_UPLOAD_AUDIO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_AUDIO_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_NAME_MAX_LEN,
|
||||
ADMIN_UPLOAD_IMAGE_EXTENSIONS,
|
||||
ADMIN_UPLOAD_IMAGE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_PDF_EXTENSIONS,
|
||||
ADMIN_UPLOAD_PDF_MAX_BYTES,
|
||||
ADMIN_UPLOAD_VIDEO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_VIDEO_MAX_BYTES,
|
||||
AdminUploadResourceTypeEnum,
|
||||
AdminUploadSceneEnum,
|
||||
)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredAdminUploadFile:
|
||||
storage_path: str
|
||||
file_url: str
|
||||
file_name: str
|
||||
original_file_name: str | None
|
||||
file_size_bytes: int
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
|
||||
|
||||
def _safe_original_name(filename: str | None) -> str | None:
|
||||
if not filename:
|
||||
return None
|
||||
name = Path(filename).name.strip()
|
||||
name = re.sub(r"[\\/\r\n\t]+", "_", name)
|
||||
return name[:ADMIN_UPLOAD_FILE_NAME_MAX_LEN] or None
|
||||
|
||||
|
||||
def _ext(filename: str | None) -> str:
|
||||
return Path(filename or "").suffix.lower()
|
||||
|
||||
|
||||
def _allowed_extensions(resource_type: AdminUploadResourceTypeEnum) -> set[str] | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_EXTENSIONS
|
||||
return None
|
||||
|
||||
|
||||
def _max_bytes(resource_type: AdminUploadResourceTypeEnum) -> int:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_MAX_BYTES
|
||||
return ADMIN_UPLOAD_FILE_MAX_BYTES
|
||||
|
||||
|
||||
def validate_file_name(filename: str | None, resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
safe_name = _safe_original_name(filename)
|
||||
if not safe_name:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
allowed = _allowed_extensions(resource_type)
|
||||
ext = _ext(safe_name)
|
||||
if allowed is not None and ext not in allowed:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="仅支持 jpg/jpeg/png/webp/gif 图片")
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp4/mov/m4v/webm 视频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp3/wav/m4a/aac 音频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="仅支持 PDF 文件")
|
||||
return safe_name
|
||||
|
||||
|
||||
def _kind_dir(resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "images"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "videos"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "audios"
|
||||
return "files"
|
||||
|
||||
|
||||
def _scene_base_dir(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum) -> Path:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
if resource_type not in {AdminUploadResourceTypeEnum.IMAGE, AdminUploadResourceTypeEnum.VIDEO, AdminUploadResourceTypeEnum.AUDIO}:
|
||||
raise HTTPException(status_code=400, detail="首页素材附件仅支持图片、视频、音频")
|
||||
return Path("home_materials") / "references" / _kind_dir(resource_type)
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_LOGO:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="系统Logo仅支持图片")
|
||||
return Path("admin_uploads") / "system_logo" / "images"
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_PDF:
|
||||
if resource_type != AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="系统PDF仅支持PDF文件")
|
||||
return Path("admin_uploads") / "system_pdf" / "files"
|
||||
if scene == AdminUploadSceneEnum.OPEN_TYPE_THUMB:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="开户方式缩略图仅支持图片")
|
||||
return Path("admin_uploads") / "open_type_thumb" / "images"
|
||||
return Path("admin_uploads") / "common" / _kind_dir(resource_type)
|
||||
|
||||
|
||||
def build_target(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str, original_filename: str | None) -> tuple[Path, str, str]:
|
||||
safe_original = validate_file_name(original_filename, resource_type)
|
||||
now = datetime.now()
|
||||
date_dir = now.strftime("%Y/%m/%d")
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
ext = _ext(safe_original)
|
||||
prefix = {
|
||||
AdminUploadResourceTypeEnum.IMAGE: "admin_img",
|
||||
AdminUploadResourceTypeEnum.VIDEO: "admin_video",
|
||||
AdminUploadResourceTypeEnum.AUDIO: "admin_audio",
|
||||
AdminUploadResourceTypeEnum.PDF: "admin_pdf",
|
||||
}.get(resource_type, "admin_file")
|
||||
file_name = f"{prefix}_{admin_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = _scene_base_dir(scene, resource_type) / date_dir
|
||||
storage_path = _upload_root() / rel_dir / file_name
|
||||
file_url = f"/uploads/{(rel_dir / file_name).as_posix()}"
|
||||
return storage_path, file_url, file_name
|
||||
|
||||
|
||||
async def save_upload_file(file: UploadFile, *, scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str) -> StoredAdminUploadFile:
|
||||
original_file_name = validate_file_name(file.filename, resource_type)
|
||||
max_bytes = _max_bytes(resource_type)
|
||||
fd, temp_path = tempfile.mkstemp(prefix="admin_upload_", suffix=".tmp")
|
||||
total = 0
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
out.write(chunk)
|
||||
final_path, file_url, file_name = build_target(scene, resource_type, admin_id, original_file_name)
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(temp_path, final_path)
|
||||
temp_path = ""
|
||||
return StoredAdminUploadFile(
|
||||
storage_path=str(final_path),
|
||||
file_url=file_url,
|
||||
file_name=file_name,
|
||||
original_file_name=original_file_name,
|
||||
file_size_bytes=total,
|
||||
)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if final_path and final_path.exists():
|
||||
try:
|
||||
final_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
Reference in New Issue
Block a user