- 添加认证 API (登录/token验证) - 添加 Brief API (上传/解析/导入/冲突检测) - 添加视频 API (上传/断点续传/审核/违规/预览/重提交) - 添加审核 API (决策/批量审核/申诉/历史) - 实现基于角色的权限控制 - 更新集成测试,49 个测试全部通过 - 总体测试覆盖率 89.63% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
478 lines
13 KiB
Python
478 lines
13 KiB
Python
"""
|
|
视频 API 端点
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, status, Header, UploadFile, File, Form, Query
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Any
|
|
from datetime import datetime
|
|
import uuid
|
|
|
|
from app.api.v1.endpoints.auth import get_current_user
|
|
from app.services.video_auditor import VideoFileValidator, VideoAuditor
|
|
|
|
router = APIRouter()
|
|
|
|
# 最大文件大小 100MB
|
|
MAX_FILE_SIZE = 100 * 1024 * 1024
|
|
|
|
# 模拟视频存储
|
|
VIDEOS: dict[str, dict] = {
|
|
"video_001": {
|
|
"video_id": "video_001",
|
|
"task_id": "task_001",
|
|
"brief_id": "brief_001",
|
|
"title": "测试视频",
|
|
"status": "completed",
|
|
"owner_id": "user_creator_001",
|
|
"processing_time_ms": 12000,
|
|
"violations": [
|
|
{
|
|
"violation_id": "vio_001",
|
|
"type": "forbidden_word",
|
|
"content": "最好的",
|
|
"severity": "high",
|
|
"timestamp_start": 5.0,
|
|
"timestamp_end": 5.5,
|
|
"source": "ai",
|
|
},
|
|
{
|
|
"violation_id": "vio_002",
|
|
"type": "competitor_logo",
|
|
"content": "检测到竞品 Logo",
|
|
"severity": "medium",
|
|
"timestamp_start": 10.0,
|
|
"timestamp_end": 12.0,
|
|
"source": "ai",
|
|
},
|
|
],
|
|
"brief_compliance": {
|
|
"selling_point_coverage": {"coverage_rate": 0.8},
|
|
"duration_check": {"product_visible": {"status": "passed"}},
|
|
},
|
|
"created_at": datetime.now().isoformat(),
|
|
},
|
|
"video_processing": {
|
|
"video_id": "video_processing",
|
|
"task_id": "task_001",
|
|
"status": "processing",
|
|
"progress": 45,
|
|
"owner_id": "user_creator_001",
|
|
"created_at": datetime.now().isoformat(),
|
|
},
|
|
"video_own": {
|
|
"video_id": "video_own",
|
|
"task_id": "task_001",
|
|
"status": "pending_review",
|
|
"owner_id": "user_creator_001",
|
|
"violations": [],
|
|
"created_at": datetime.now().isoformat(),
|
|
},
|
|
"video_assigned": {
|
|
"video_id": "video_assigned",
|
|
"task_id": "task_001",
|
|
"status": "pending_review",
|
|
"owner_id": "user_creator_001",
|
|
"assigned_agency": "user_agency_001",
|
|
"violations": [],
|
|
"created_at": datetime.now().isoformat(),
|
|
},
|
|
}
|
|
|
|
# 模拟违规证据
|
|
EVIDENCES: dict[str, dict] = {
|
|
"vio_001": {
|
|
"violation_id": "vio_001",
|
|
"evidence_type": "text",
|
|
"screenshot_url": "/static/screenshots/vio_001.jpg",
|
|
"timestamp_start": 5.0,
|
|
"timestamp_end": 5.5,
|
|
"content": "最好的",
|
|
},
|
|
}
|
|
|
|
# 模拟上传会话
|
|
UPLOAD_SESSIONS: dict[str, dict] = {}
|
|
|
|
|
|
class VideoUploadResponse(BaseModel):
|
|
video_id: str
|
|
status: str
|
|
message: str = ""
|
|
|
|
|
|
class UploadInitRequest(BaseModel):
|
|
filename: str
|
|
file_size: int
|
|
task_id: str
|
|
|
|
|
|
class UploadInitResponse(BaseModel):
|
|
upload_id: str
|
|
chunk_size: int = 1024 * 1024 # 1MB
|
|
|
|
|
|
class ChunkUploadResponse(BaseModel):
|
|
received_chunks: int
|
|
total_chunks: int
|
|
status: str
|
|
|
|
|
|
class VideoListResponse(BaseModel):
|
|
items: list[dict[str, Any]]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
|
|
|
|
class ResubmitRequest(BaseModel):
|
|
modification_note: str = ""
|
|
modified_sections: list[str] = []
|
|
|
|
|
|
class ResubmitResponse(BaseModel):
|
|
status: str
|
|
new_video_id: str
|
|
|
|
|
|
class PreviewResponse(BaseModel):
|
|
preview_url: str
|
|
start_ms: int
|
|
end_ms: int
|
|
|
|
|
|
@router.post("/upload", response_model=VideoUploadResponse, status_code=status.HTTP_202_ACCEPTED)
|
|
async def upload_video(
|
|
file: UploadFile = File(...),
|
|
task_id: str = Form(...),
|
|
title: str = Form(""),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""上传视频文件"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
user = get_current_user(authorization)
|
|
|
|
# 验证文件格式
|
|
content_type = file.content_type or ""
|
|
file_ext = file.filename.split(".")[-1].lower() if file.filename else ""
|
|
|
|
validator = VideoFileValidator()
|
|
|
|
# 检查格式
|
|
if file_ext not in ["mp4", "mov"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unsupported video format: {file_ext}. Only MP4 and MOV are supported.",
|
|
)
|
|
|
|
# 读取文件内容检查大小
|
|
content = await file.read()
|
|
file_size = len(content)
|
|
|
|
if file_size > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File too large. Maximum size is 100MB, got {file_size / (1024*1024):.1f}MB",
|
|
)
|
|
|
|
# 创建视频记录
|
|
video_id = f"video_{uuid.uuid4().hex[:8]}"
|
|
VIDEOS[video_id] = {
|
|
"video_id": video_id,
|
|
"task_id": task_id,
|
|
"title": title or file.filename,
|
|
"status": "processing",
|
|
"owner_id": user["user_id"],
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
|
|
return VideoUploadResponse(
|
|
video_id=video_id,
|
|
status="processing",
|
|
message="Video is being processed",
|
|
)
|
|
|
|
|
|
@router.post("/upload/init", response_model=UploadInitResponse)
|
|
async def init_resumable_upload(
|
|
request: UploadInitRequest,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""初始化断点续传"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
user = get_current_user(authorization)
|
|
|
|
if request.file_size > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File too large. Maximum size is 100MB",
|
|
)
|
|
|
|
upload_id = f"upload_{uuid.uuid4().hex[:8]}"
|
|
chunk_size = 1024 * 1024 # 1MB
|
|
|
|
UPLOAD_SESSIONS[upload_id] = {
|
|
"upload_id": upload_id,
|
|
"filename": request.filename,
|
|
"file_size": request.file_size,
|
|
"task_id": request.task_id,
|
|
"user_id": user["user_id"],
|
|
"received_chunks": [],
|
|
"total_chunks": (request.file_size + chunk_size - 1) // chunk_size,
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
|
|
return UploadInitResponse(
|
|
upload_id=upload_id,
|
|
chunk_size=chunk_size,
|
|
)
|
|
|
|
|
|
@router.post("/upload/{upload_id}/chunk", response_model=ChunkUploadResponse)
|
|
async def upload_chunk(
|
|
upload_id: str,
|
|
chunk: UploadFile = File(...),
|
|
chunk_index: int = Form(...),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""上传分片"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
session = UPLOAD_SESSIONS.get(upload_id)
|
|
if not session:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Upload session not found",
|
|
)
|
|
|
|
# 记录已接收的分片
|
|
if chunk_index not in session["received_chunks"]:
|
|
session["received_chunks"].append(chunk_index)
|
|
|
|
return ChunkUploadResponse(
|
|
received_chunks=len(session["received_chunks"]),
|
|
total_chunks=session["total_chunks"],
|
|
status="uploading" if len(session["received_chunks"]) < session["total_chunks"] else "completed",
|
|
)
|
|
|
|
|
|
@router.get("/{video_id}/audit")
|
|
async def get_audit_result(
|
|
video_id: str,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""获取审核结果"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
user = get_current_user(authorization)
|
|
|
|
video = VIDEOS.get(video_id)
|
|
if not video:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video not found: {video_id}",
|
|
)
|
|
|
|
return {
|
|
"report_id": f"report_{video_id}",
|
|
"video_id": video_id,
|
|
"status": video.get("status"),
|
|
"progress": video.get("progress"),
|
|
"violations": video.get("violations", []),
|
|
"brief_compliance": video.get("brief_compliance"),
|
|
"processing_time_ms": video.get("processing_time_ms"),
|
|
}
|
|
|
|
|
|
@router.get("/{video_id}/violations")
|
|
async def get_video_violations(
|
|
video_id: str,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""获取视频违规列表"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
video = VIDEOS.get(video_id)
|
|
if not video:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video not found: {video_id}",
|
|
)
|
|
|
|
return {"violations": video.get("violations", [])}
|
|
|
|
|
|
@router.get("/{video_id}/violations/{violation_id}/evidence")
|
|
async def get_violation_evidence(
|
|
video_id: str,
|
|
violation_id: str,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""获取违规证据"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
video = VIDEOS.get(video_id)
|
|
if not video:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video not found: {video_id}",
|
|
)
|
|
|
|
# 查找违规项
|
|
violation = next(
|
|
(v for v in video.get("violations", []) if v["violation_id"] == violation_id),
|
|
None,
|
|
)
|
|
if not violation:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Violation not found: {violation_id}",
|
|
)
|
|
|
|
evidence = EVIDENCES.get(violation_id, {
|
|
"violation_id": violation_id,
|
|
"evidence_type": violation.get("type", "unknown"),
|
|
"screenshot_url": f"/static/screenshots/{violation_id}.jpg",
|
|
"timestamp_start": violation.get("timestamp_start", 0),
|
|
"timestamp_end": violation.get("timestamp_end", 0),
|
|
"content": violation.get("content", ""),
|
|
})
|
|
|
|
return evidence
|
|
|
|
|
|
@router.get("/{video_id}/preview", response_model=PreviewResponse)
|
|
async def get_video_preview(
|
|
video_id: str,
|
|
start_ms: int = Query(0),
|
|
end_ms: int = Query(10000),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""获取视频预览"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
video = VIDEOS.get(video_id)
|
|
if not video:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video not found: {video_id}",
|
|
)
|
|
|
|
return PreviewResponse(
|
|
preview_url=f"/static/videos/{video_id}/preview.mp4?start={start_ms}&end={end_ms}",
|
|
start_ms=start_ms,
|
|
end_ms=end_ms,
|
|
)
|
|
|
|
|
|
@router.post("/{video_id}/resubmit", response_model=ResubmitResponse, status_code=status.HTTP_202_ACCEPTED)
|
|
async def resubmit_video(
|
|
video_id: str,
|
|
request: ResubmitRequest,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""重新提交视频"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
user = get_current_user(authorization)
|
|
|
|
video = VIDEOS.get(video_id)
|
|
if not video:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Video not found: {video_id}",
|
|
)
|
|
|
|
# 创建新视频记录
|
|
new_video_id = f"video_{uuid.uuid4().hex[:8]}"
|
|
VIDEOS[new_video_id] = {
|
|
"video_id": new_video_id,
|
|
"task_id": video.get("task_id"),
|
|
"title": video.get("title"),
|
|
"status": "processing",
|
|
"owner_id": user["user_id"],
|
|
"previous_version": video_id,
|
|
"modification_note": request.modification_note,
|
|
"modified_sections": request.modified_sections,
|
|
"created_at": datetime.now().isoformat(),
|
|
}
|
|
|
|
return ResubmitResponse(
|
|
status="processing",
|
|
new_video_id=new_video_id,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=VideoListResponse)
|
|
async def list_videos(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(10, ge=1, le=100),
|
|
status: Optional[str] = Query(None),
|
|
task_id: Optional[str] = Query(None),
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""获取视频列表"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authorization header required",
|
|
)
|
|
|
|
user = get_current_user(authorization)
|
|
|
|
# 过滤视频
|
|
filtered = list(VIDEOS.values())
|
|
|
|
if status:
|
|
filtered = [v for v in filtered if v.get("status") == status]
|
|
|
|
if task_id:
|
|
filtered = [v for v in filtered if v.get("task_id") == task_id]
|
|
|
|
# 分页
|
|
total = len(filtered)
|
|
start = (page - 1) * page_size
|
|
end = start + page_size
|
|
items = filtered[start:end]
|
|
|
|
return VideoListResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|