Files
meijiaka-zy/python-api/app/api/v1/upload.py
T
小鱼开发 b597d715c8 fix: 认证流程修复 + alembic 迁移补全 + 前端僵尸代码清理
后端:
- 修复 get_current_user 未校验 is_active,被封禁用户仍可用旧 Token
- auth.py 捕获 ValueError 转 HTTPException(验证码错误、账号被封、Token 无效等不再返回 500)
- 修正 SMS 每日上限注释(3次 → 10次)
- 修复迁移脚本外键引用错误:users.id → mjk_users.id
- 新建积分系统 4 张表的迁移(mjk_user_points/batches/transactions/recharge_orders)
- pyproject.toml 补充 alembic + psycopg2-binary 依赖
- ruff 格式修复(import 排序等)

前端:
- 修复 doRefreshToken 成功后不持久化新 Token 的严重 bug
- 修复应用重启后 SSE 不自动重连(收不到踢人通知)
- 修复 App.tsx handleLogout 未 await
- client.ts 统一从 utils/env 导入 isTauri,默认 base URL 兜底 localhost:8000
- 清理 ~20 个未使用的 hooks/utils/api 模块/组件导出
- 修复所有 ESLint 警告(206 → 0)和 TSC 错误
- 测试通过(5/5)

其他:
- 更新 requirements.lock 和 uv.lock
2026-05-08 11:10:48 +08:00

273 lines
8.1 KiB
Python

"""
文件上传 API
============
提供通用文件上传功能,直接上传到七牛云对象存储。
"""
import io
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.schemas.common import ApiResponse, success_response
from app.services.qiniu_service import get_qiniu_service
router = APIRouter(prefix="/upload", tags=["Upload"])
logger = logging.getLogger(__name__)
class UploadResponse(BaseModel):
"""上传响应"""
url: str = Field(..., description="七牛云文件 URL")
key: str = Field(..., description="七牛云文件 key")
size: int = Field(..., description="文件大小(字节)")
@router.post("/video", response_model=ApiResponse[UploadResponse])
async def upload_video(
file: UploadFile = File(..., description="视频文件"),
):
"""
上传视频到七牛云
支持格式:mp4, mov, avi, webm
返回七牛云永久访问 URL。
"""
try:
# 验证文件格式
allowed_types = {
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/webm",
}
content_type = file.content_type or ""
# 如果 content_type 为空,尝试从文件名推断
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".webm": "video/webm",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的文件格式: {content_type},请上传 mp4/mov/avi/webm 视频",
)
# 读取文件内容
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
# 生成唯一文件名
ext = Path(file.filename or "video.mp4").suffix or ".mp4"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
# 上传到七牛云
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("video")
key = qiniu.generate_key("video", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=key,
mime_type=content_type or "video/mp4",
bucket=bucket,
domain=domain,
)
url = result.get("url")
key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 视频上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=UploadResponse(
url=url,
key=key or unique_name,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 视频上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/image", response_model=ApiResponse[UploadResponse])
async def upload_image(
file: UploadFile = File(..., description="图片文件"),
):
"""
上传图片到七牛云
支持格式:jpg, png, gif, webp
"""
try:
allowed_types = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
}
content_type = file.content_type or ""
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的图片格式: {content_type}",
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
qiniu = get_qiniu_service()
bucket, domain = qiniu._get_bucket_and_domain("image")
key = qiniu.generate_key("image", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=key,
mime_type=content_type or "image/jpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 图片上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=UploadResponse(
url=url,
key=key or unique_name,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 图片上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")
@router.post("/audio", response_model=ApiResponse[UploadResponse])
async def upload_audio(
file: UploadFile = File(..., description="音频文件"),
):
"""
上传音频到七牛云
支持格式:mp3, wav, aac, m4a, ogg, flac
"""
try:
allowed_types = {
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/aac",
"audio/mp4",
"audio/ogg",
"audio/flac",
"audio/x-flac",
}
content_type = file.content_type or ""
if not content_type:
ext = Path(file.filename or "").suffix.lower()
ext_to_mime = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".aac": "audio/aac",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".flac": "audio/flac",
}
content_type = ext_to_mime.get(ext, "")
if content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的音频格式: {content_type},请上传 mp3/wav/aac/m4a/ogg/flac",
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="文件内容为空")
ext = Path(file.filename or "audio.mp3").suffix or ".mp3"
unique_name = f"{uuid.uuid4().hex[:16]}{ext}"
qiniu = get_qiniu_service()
# 复用视频 bucket(或根据配置使用音频 bucket)
bucket, domain = qiniu._get_bucket_and_domain("video")
key = qiniu.generate_key("audio", unique_name)
stream = io.BytesIO(content)
result = await qiniu.upload_stream_async(
stream=stream,
key=key,
mime_type=content_type or "audio/mpeg",
bucket=bucket,
domain=domain,
)
url = result.get("url")
key = result.get("key")
if not url:
raise HTTPException(status_code=500, detail="上传到七牛云失败:未返回 URL")
logger.info(f"[Upload] 音频上传成功: {url[:80]}..., size={len(content)}")
return success_response(
data=UploadResponse(
url=url,
key=key or unique_name,
size=len(content),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[Upload] 音频上传失败: {e}")
raise HTTPException(status_code=500, detail=f"上传失败: {e}")