Your Name 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:11:54 +08:00

344 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
"""
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.services.oss import generate_upload_policy, get_file_url, generate_presigned_url
from app.config import settings
from app.models.user import User
from app.api.deps import get_current_user
router = APIRouter(prefix="/upload", tags=["文件上传"])
class UploadPolicyRequest(BaseModel):
"""获取上传凭证请求"""
file_type: str = "general" # script, video, image, general
file_name: Optional[str] = None
class UploadPolicyResponse(BaseModel):
"""TOS 直传凭证响应"""
x_tos_algorithm: str
x_tos_credential: str
x_tos_date: str
x_tos_signature: str
policy: str
host: str
dir: str
expire: int
max_size_mb: int
class FileUploadedRequest(BaseModel):
"""文件上传完成回调"""
file_key: str
file_name: str
file_size: int
file_type: str
class FileUploadedResponse(BaseModel):
"""文件上传完成响应"""
url: str
file_key: str
file_name: str
file_size: int
file_type: str
@router.post("/policy", response_model=UploadPolicyResponse)
async def get_upload_policy(
request: UploadPolicyRequest,
current_user: User = Depends(get_current_user),
):
"""
获取 TOS 直传凭证
前端使用此凭证直接上传文件到火山引擎 TOS无需经过后端。
文件类型说明:
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
- video: 视频文件 (mp4, mov, webm)
- image: 图片文件 (jpg, png, gif)
- general: 通用文件
"""
# 根据文件类型设置上传目录
now = datetime.now()
base_dir = f"uploads/{now.year}/{now.month:02d}"
if request.file_type == "script":
upload_dir = f"{base_dir}/scripts/"
elif request.file_type == "video":
upload_dir = f"{base_dir}/videos/"
elif request.file_type == "image":
upload_dir = f"{base_dir}/images/"
else:
upload_dir = f"{base_dir}/files/"
try:
policy = generate_upload_policy(
max_size_mb=settings.MAX_FILE_SIZE_MB,
expire_seconds=3600,
upload_dir=upload_dir,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
return UploadPolicyResponse(
x_tos_algorithm=policy["x_tos_algorithm"],
x_tos_credential=policy["x_tos_credential"],
x_tos_date=policy["x_tos_date"],
x_tos_signature=policy["x_tos_signature"],
policy=policy["policy"],
host=policy["host"],
dir=policy["dir"],
expire=policy["expire"],
max_size_mb=settings.MAX_FILE_SIZE_MB,
)
@router.post("/complete", response_model=FileUploadedResponse)
async def file_uploaded(
request: FileUploadedRequest,
current_user: User = Depends(get_current_user),
):
"""
文件上传完成回调
前端上传完成后调用此接口,获取文件的完整 URL。
"""
url = get_file_url(request.file_key)
return FileUploadedResponse(
url=url,
file_key=request.file_key,
file_name=request.file_name,
file_size=request.file_size,
file_type=request.file_type,
)
class SignedUrlResponse(BaseModel):
"""签名 URL 响应"""
signed_url: str
expire_seconds: int
@router.get("/sign-url", response_model=SignedUrlResponse)
async def get_signed_url(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
expire: int = Query(3600, ge=60, le=43200, description="有效期默认1小时最长12小时"),
current_user: User = Depends(get_current_user),
):
"""
获取私有桶文件的预签名访问 URL
前端在展示/下载文件前调用此接口,获取带签名的临时访问链接。
支持传入完整 URL 或 file_key。
"""
from app.services.oss import parse_file_key_from_url
# 如果传入的是完整 URL先解析出 file_key
file_key = url
if url.startswith("http"):
file_key = parse_file_key_from_url(url)
if not file_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的文件路径",
)
try:
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
return SignedUrlResponse(
signed_url=signed_url,
expire_seconds=expire,
)
def _get_tos_object(file_key: str) -> tuple[bytes, str]:
"""
从 TOS 获取文件内容和文件名(内部工具函数)
Returns:
(content, filename)
"""
import tos as tos_sdk
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
client = tos_sdk.TosClientV2(
ak=settings.TOS_ACCESS_KEY_ID,
sk=settings.TOS_SECRET_ACCESS_KEY,
endpoint=f"https://{endpoint}",
region=region,
)
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
content = resp.read()
# 从 file_key 提取文件名,去掉时间戳前缀
filename = file_key.split("/")[-1]
if "_" in filename and filename.split("_")[0].isdigit():
filename = filename.split("_", 1)[1]
return content, filename
def _resolve_file_key(url: str) -> str:
"""从 URL 或 file_key 解析出实际 file_key"""
from app.services.oss import parse_file_key_from_url
file_key = url
if url.startswith("http"):
file_key = parse_file_key_from_url(url)
return file_key
def _guess_content_type(filename: str) -> str:
"""根据文件名猜测 MIME 类型"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
mime_map = {
"pdf": "application/pdf",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"mov": "video/quicktime",
"webm": "video/webm",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"txt": "text/plain",
}
return mime_map.get(ext, "application/octet-stream")
@router.get("/download")
async def download_file(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
current_user: User = Depends(get_current_user),
):
"""
代理下载文件 — 后端获取 TOS 文件后返回给前端,
设置 Content-Disposition: attachment 确保浏览器触发下载。
"""
file_key = _resolve_file_key(url)
if not file_key:
raise HTTPException(status_code=400, detail="无效的文件路径")
try:
content, filename = _get_tos_object(file_key)
except Exception as e:
raise HTTPException(status_code=502, detail=f"下载文件失败: {e}")
from fastapi.responses import Response
encoded_filename = quote(filename)
return Response(
content=content,
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
},
)
@router.get("/preview")
async def preview_file(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
current_user: User = Depends(get_current_user),
):
"""
代理预览文件 — 后端获取 TOS 文件后返回给前端,
设置正确的 Content-Type 让浏览器可以直接渲染PDF / 图片等)。
"""
file_key = _resolve_file_key(url)
if not file_key:
raise HTTPException(status_code=400, detail="无效的文件路径")
try:
content, filename = _get_tos_object(file_key)
except Exception as e:
raise HTTPException(status_code=502, detail=f"获取文件失败: {e}")
from fastapi.responses import Response
content_type = _guess_content_type(filename)
return Response(
content=content,
media_type=content_type,
)
@router.post("/proxy", response_model=FileUploadedResponse)
async def proxy_upload(
file: UploadFile = File(...),
file_type: str = Form("general"),
current_user: User = Depends(get_current_user),
):
"""
后端代理上传(用于本地开发 / 浏览器无法直连 TOS 的场景)
前端把文件 POST 到此接口,后端使用 TOS SDK 上传到对象存储。
"""
import io
import tos as tos_sdk
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise HTTPException(status_code=500, detail="TOS 配置未设置")
now = datetime.now()
base_dir = f"uploads/{now.year}/{now.month:02d}"
type_dirs = {"script": "scripts", "video": "videos", "image": "images"}
sub_dir = type_dirs.get(file_type, "files")
file_key = f"{base_dir}/{sub_dir}/{int(now.timestamp())}_{file.filename}"
content = await file.read()
content_type = file.content_type or "application/octet-stream"
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
try:
client = tos_sdk.TosClientV2(
ak=settings.TOS_ACCESS_KEY_ID,
sk=settings.TOS_SECRET_ACCESS_KEY,
endpoint=f"https://{endpoint}",
region=region,
)
client.put_object(
bucket=settings.TOS_BUCKET_NAME,
key=file_key,
content=io.BytesIO(content),
content_type=content_type,
)
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"TOS 上传失败: {str(e)[:200]}",
)
url = get_file_url(file_key)
return FileUploadedResponse(
url=url,
file_key=file_key,
file_name=file.filename or "unknown",
file_size=len(content),
file_type=file_type,
)