管理后台上传集成API | 首页素材装修追加提词/附件

This commit is contained in:
2026-07-09 10:40:23 +08:00
parent 465ed9fa04
commit 0148bea66a
22 changed files with 1296 additions and 63 deletions
@@ -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