340 lines
10 KiB
Python
340 lines
10 KiB
Python
"""
|
||
七牛云对象存储 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}")
|