""" 七牛云对象存储 API 路由 ======================== 提供音视频文件上传、管理和访问功能。 主要功能: 1. 生成上传凭证(客户端直传) 2. 服务端文件上传 3. 声音克隆样本上传 4. 文件删除和管理 """ import contextlib import logging import os import shutil import tempfile from pathlib import Path logger = logging.getLogger(__name__) from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from pydantic import BaseModel, Field from app.api.deps import get_current_user from app.models.user import User from app.schemas.common import ApiResponse, success_response from app.services.qiniu_service import get_qiniu_service router = APIRouter(prefix="/qiniu", tags=["Qiniu Storage"]) # ============ 请求/响应模型 ============ class UploadTokenRequest(BaseModel): """上传凭证请求""" key: str = Field(..., description="文件存储 Key") expires: int = Field(3600, description="Token 有效期(秒)") class UploadTokenResponse(BaseModel): """上传凭证响应""" token: str key: str uploadUrl: str = "https://upload.qiniup.com" class FileUploadResponse(BaseModel): """文件上传响应""" key: str url: str hash: str mimeType: str fsize: int isDuplicate: bool = False message: str | None = None existingTaskId: str | None = None # 当检测到重复任务时返回 class DeleteFileRequest(BaseModel): """删除文件请求""" key: str = Field(..., description="文件 Key") # ============ API 路由 ============ @router.post("/upload-token", response_model=ApiResponse[UploadTokenResponse]) async def get_upload_token(request: UploadTokenRequest): """ 获取上传凭证(客户端直传) 前端获取 Token 后,可直接上传到七牛云,无需经过服务端。 上传地址: https://upload.qiniup.com 请求方式: POST (multipart/form-data) 请求参数: - token: 上传凭证(本接口返回) - key: 文件存储 Key(本接口返回) - file: 文件内容 """ try: service = get_qiniu_service() token = service.get_upload_token(request.key, request.expires) return success_response( data=UploadTokenResponse( token=token, key=request.key, uploadUrl="https://upload.qiniup.com" ) ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"生成上传凭证失败: {e}") @router.post("/upload/audio", response_model=ApiResponse[FileUploadResponse]) async def upload_audio( file: UploadFile = File(..., description="音频文件(MP3, WAV, M4A, AAC, OGG)"), userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), ): """ 上传音频文件 支持格式: MP3, WAV, M4A, AAC, OGG 文件会自动存储到: audios/{userId}/{date}/{uuid}.{ext} """ service = get_qiniu_service() # 保存临时文件 suffix = Path(file.filename).suffix if file.filename else ".mp3" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: shutil.copyfileobj(file.file, tmp) tmp_path = tmp.name try: result = service.upload_audio(tmp_path, userId=userId) return success_response(data=FileUploadResponse(**result)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"上传失败: {e}") finally: os.unlink(tmp_path) @router.post("/upload/video", response_model=ApiResponse[FileUploadResponse]) async def upload_video( file: UploadFile = File(..., description="视频文件(MP4, MOV, AVI, WebM)"), userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), ): """ 上传视频文件 支持格式: MP4, MOV, AVI, WebM 文件会自动存储到: videos/{userId}/{date}/{uuid}.{ext} """ service = get_qiniu_service() suffix = Path(file.filename).suffix if file.filename else ".mp4" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: shutil.copyfileobj(file.file, tmp) tmp_path = tmp.name try: result = service.upload_video(tmp_path, userId=userId) return success_response(data=FileUploadResponse(**result)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"上传失败: {e}") finally: os.unlink(tmp_path) async def _check_existing_avatar_task( video_url: str, user_id: str, ) -> dict | None: """ 检查是否有相同视频URL的正在进行的任务(从 Redis 读取) Returns: 如果找到进行中的任务,返回 {'task_id': str, 'status': str} 否则返回 None """ import json from app.core.redis_client import get_redis_client from app.scheduler.registry import JobRegistry redis = get_redis_client() registry = JobRegistry(redis) job_ids = await registry.get_running_job_ids() for job_id in job_ids: data = await redis.hgetall(f"job:{job_id}") if not data: continue if data.get("type") != "avatar_clone": continue params = {} if "params" in data and data["params"]: with contextlib.suppress(json.JSONDecodeError): params = json.loads(data["params"]) if params.get("user_id") == user_id and params.get("video_url") == video_url: avatar_status = data.get("avatar_status", data.get("status", "")) return { "task_id": job_id, "status": avatar_status, "voice_id": data.get("voice_id"), "provider_element_id": data.get("provider_element_id"), "video_url": video_url, "file_size": 0, } return None @router.post("/upload/avatar", response_model=ApiResponse[FileUploadResponse]) async def upload_avatar( file: UploadFile = File(..., description="形象克隆视频(MP4, MOV)"), userId: str | None = Form(None, description="用户ID(可选,用于目录隔离)"), fileHash: str | None = Form(None, description="前端计算的文件SHA256哈希,用于重复检测"), current_user: User = Depends(get_current_user), ): """ 上传形象克隆视频 用于形象克隆功能,上传的视频将同时用于创建自定义音色和主体。 KlingAI 要求: - 格式: MP4, MOV - 时长: 5-30 秒 (建议 5-8 秒) - 大小: 不超过 200MB - 分辨率: 高度 720px~2160px - 内容: 写实风格人物正面特写,人脸清晰、无遮挡,视频中有清晰人声 文件存储路径: meijiaka/avatars/{userId}/{date}/{uuid}.{ext} 重复检测: - 如果提供了 fileHash,会检查是否已有相同文件的任务在进行中 - 返回的 isDuplicate 表示是否复用了已有资源 - existingTaskId 表示已存在任务的ID(如果有) """ service = get_qiniu_service() # 使用当前登录用户的ID effective_user_id = userId or str(current_user.id) suffix = Path(file.filename).suffix if file.filename else ".mp4" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: shutil.copyfileobj(file.file, tmp) tmp_path = tmp.name try: result = service.upload_avatar_video( tmp_path, user_id=effective_user_id, file_hash=fileHash, ) # 如果七牛云返回了现有文件,检查数据库中是否有进行中的任务 if result.get("isDuplicate") and result.get("url"): existing_task = await _check_existing_avatar_task(result["url"], effective_user_id) if existing_task: logger.info( f"Found existing avatar task for uploaded file: {existing_task['task_id']}" ) result["existingTaskId"] = existing_task["task_id"] result["message"] = "检测到相同视频的任务正在进行中,已复用现有任务" return success_response(data=FileUploadResponse(**result)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.exception("Upload avatar failed") raise HTTPException(status_code=500, detail=f"上传失败: {e}") finally: os.unlink(tmp_path) @router.get("/files/{key:path}", response_model=ApiResponse[dict]) async def get_file_info(key: str): """ 获取文件信息 Args: key: 文件存储 Key(路径格式) """ try: service = get_qiniu_service() # 根据 key 推断 bucket bucket = service.image_bucket if "/images/" in key else service.video_bucket info = service.get_file_info(bucket, key) if info is None: raise HTTPException(status_code=404, detail="文件不存在") return success_response(data=info) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"获取文件信息失败: {e}") @router.delete("/files/{key:path}", response_model=ApiResponse[dict]) async def delete_file(key: str): """ 删除文件 Args: key: 文件存储 Key """ try: service = get_qiniu_service() # 根据 key 推断 bucket bucket = service.image_bucket if "/images/" in key else service.video_bucket success = service.delete_file(bucket, key) return success_response( data={ "success": success, "key": key, "message": "删除成功" if success else "删除失败或文件不存在", } ) except Exception as e: raise HTTPException(status_code=500, detail=f"删除失败: {e}") @router.post("/refresh-cdn", response_model=ApiResponse[dict]) async def refresh_cdn(keys: list[str]): """ 刷新 CDN 缓存 文件更新后,调用此接口刷新 CDN 缓存,确保用户访问到最新内容。 """ try: service = get_qiniu_service() result = service.refresh_cdn(keys) return success_response(data=result) except Exception as e: raise HTTPException(status_code=500, detail=f"刷新 CDN 失败: {e}")