Files
meijiaka-zy/python-api/app/api/v1/qiniu.py
T

340 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
七牛云对象存储 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}")