Your Name f87ae48ad5 feat: 实现 FastAPI REST API 端点和集成测试
- 添加认证 API (登录/token验证)
- 添加 Brief API (上传/解析/导入/冲突检测)
- 添加视频 API (上传/断点续传/审核/违规/预览/重提交)
- 添加审核 API (决策/批量审核/申诉/历史)
- 实现基于角色的权限控制
- 更新集成测试,49 个测试全部通过
- 总体测试覆盖率 89.63%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:08:12 +08:00

659 lines
18 KiB
Python

"""
审核决策 API 端点
"""
from fastapi import APIRouter, HTTPException, status, Header
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, get_user_by_id, update_user_tokens
router = APIRouter()
# 模拟视频数据引用(实际使用时应该通过服务层访问)
VIDEOS: dict[str, dict] = {
"video_001": {
"video_id": "video_001",
"status": "pending_review",
"owner_id": "user_creator_001",
"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",
},
],
},
"video_002": {
"video_id": "video_002",
"status": "pending_review",
"owner_id": "user_creator_002",
"violations": [],
},
"video_003": {
"video_id": "video_003",
"status": "pending_review",
"owner_id": "user_creator_003",
"violations": [],
},
"video_own": {
"video_id": "video_own",
"status": "pending_review",
"owner_id": "user_creator_001",
"violations": [],
},
"video_assigned": {
"video_id": "video_assigned",
"status": "pending_review",
"owner_id": "user_creator_001",
"assigned_agency": "user_agency_001",
"violations": [],
},
}
# 模拟审核历史
REVIEW_HISTORY: dict[str, list[dict]] = {}
# 模拟申诉存储
APPEALS: dict[str, dict] = {
"appeal_001": {
"appeal_id": "appeal_001",
"video_id": "video_001",
"user_id": "user_creator_001",
"violation_ids": ["vio_001"],
"reason": "这个词语在此语境下是正常使用",
"status": "pending",
"created_at": datetime.now().isoformat(),
},
}
class ReviewDecisionRequest(BaseModel):
decision: str # passed, rejected, force_passed
selected_violations: list[str] = []
comment: str = ""
force_pass_reason: str = ""
class ReviewDecisionResponse(BaseModel):
review_id: str
status: str
selected_violations: list[str] = []
force_pass_reason: Optional[str] = None
class AddViolationRequest(BaseModel):
type: str
content: str
timestamp_start: float
timestamp_end: float
severity: str = "medium"
class AddViolationResponse(BaseModel):
violation_id: str
source: str = "manual"
type: str
content: str
severity: str
class DeleteViolationRequest(BaseModel):
delete_reason: str = ""
class DeleteViolationResponse(BaseModel):
status: str
class ModifyViolationRequest(BaseModel):
severity: str
modify_reason: str = ""
class ModifyViolationResponse(BaseModel):
violation_id: str
severity: str
class AppealRequest(BaseModel):
violation_ids: list[str]
reason: str
class AppealResponse(BaseModel):
appeal_id: str
status: str
class ProcessAppealRequest(BaseModel):
decision: str # approved, rejected
comment: str = ""
class ProcessAppealResponse(BaseModel):
appeal_id: str
status: str
class ReviewHistoryResponse(BaseModel):
history: list[dict[str, Any]]
class BatchDecisionRequest(BaseModel):
video_ids: list[str]
decision: str
comment: str = ""
class BatchDecisionResponse(BaseModel):
processed_count: int
success_count: int
failure_count: int = 0
failures: list[dict[str, str]] = []
def check_review_permission(user: dict, video: dict) -> bool:
"""检查用户是否有审核权限"""
role = user.get("role")
user_id = user.get("user_id")
# 达人不能审核自己的视频
if role == "creator" and video.get("owner_id") == user_id:
return False
# 品牌方不能做决策
if role == "brand":
return False
# Agency 只能审核分配给自己的视频
if role == "agency":
assigned_agency = video.get("assigned_agency")
if assigned_agency and assigned_agency == user_id:
return True
return False
# 审核员可以审核所有视频
if role == "reviewer":
return True
return False
def add_history_entry(video_id: str, action: str, actor: str, details: dict = None):
"""添加审核历史记录"""
if video_id not in REVIEW_HISTORY:
REVIEW_HISTORY[video_id] = []
entry = {
"timestamp": datetime.now().isoformat(),
"action": action,
"actor": actor,
"details": details or {},
}
REVIEW_HISTORY[video_id].append(entry)
# ==================== 静态路由必须放在动态路由之前 ====================
@router.post("/batch/decision", response_model=BatchDecisionResponse)
async def batch_review_decision(
request: BatchDecisionRequest,
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)
processed_count = len(request.video_ids)
success_count = 0
failures = []
for video_id in request.video_ids:
video = VIDEOS.get(video_id)
if not video:
failures.append({"video_id": video_id, "error": "Video not found"})
continue
if not check_review_permission(user, video):
failures.append({"video_id": video_id, "error": "Permission denied"})
continue
# 更新视频状态
video["status"] = request.decision
success_count += 1
# 添加历史记录
add_history_entry(
video_id,
f"batch_review_{request.decision}",
user["user_id"],
{"comment": request.comment},
)
failure_count = len(failures)
return BatchDecisionResponse(
processed_count=processed_count,
success_count=success_count,
failure_count=failure_count,
failures=failures,
)
@router.post("/appeals/{appeal_id}/process", response_model=ProcessAppealResponse)
async def process_appeal(
appeal_id: str,
request: ProcessAppealRequest,
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)
appeal = APPEALS.get(appeal_id)
if not appeal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Appeal not found: {appeal_id}",
)
if request.decision not in ["approved", "rejected"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid decision type",
)
# 更新申诉状态
appeal["status"] = request.decision
appeal["processed_by"] = user["user_id"]
appeal["processed_at"] = datetime.now().isoformat()
appeal["process_comment"] = request.comment
# 如果申诉成功,返还令牌
if request.decision == "approved":
update_user_tokens(appeal["user_id"], 1)
# 添加历史记录
video_id = appeal["video_id"]
add_history_entry(
video_id,
f"appeal_{request.decision}",
user["user_id"],
{"appeal_id": appeal_id, "comment": request.comment},
)
return ProcessAppealResponse(
appeal_id=appeal_id,
status=request.decision,
)
# ==================== 动态路由 ====================
@router.post("/{video_id}/decision", response_model=ReviewDecisionResponse)
async def submit_review_decision(
video_id: str,
request: ReviewDecisionRequest,
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}",
)
# 检查权限
if not check_review_permission(user, video):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to review this video",
)
# 验证决策类型
if request.decision not in ["passed", "rejected", "force_passed"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid decision type",
)
# 驳回必须选择违规项
if request.decision == "rejected":
if not request.selected_violations:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "驳回必须选择至少一个违规项"},
)
# 强制通过必须填写原因
if request.decision == "force_passed":
if not request.force_pass_reason:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "强制通过必须填写原因"},
)
# 更新视频状态
video["status"] = request.decision
# 创建审核记录
review_id = f"review_{uuid.uuid4().hex[:8]}"
# 添加历史记录
add_history_entry(
video_id,
f"review_{request.decision}",
user["user_id"],
{"comment": request.comment},
)
return ReviewDecisionResponse(
review_id=review_id,
status=request.decision,
selected_violations=request.selected_violations,
force_pass_reason=request.force_pass_reason if request.decision == "force_passed" else None,
)
@router.post("/{video_id}/violations", response_model=AddViolationResponse, status_code=status.HTTP_201_CREATED)
async def add_manual_violation(
video_id: str,
request: AddViolationRequest,
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}",
)
violation_id = f"vio_{uuid.uuid4().hex[:8]}"
violation = {
"violation_id": violation_id,
"type": request.type,
"content": request.content,
"severity": request.severity,
"timestamp_start": request.timestamp_start,
"timestamp_end": request.timestamp_end,
"source": "manual",
}
if "violations" not in video:
video["violations"] = []
video["violations"].append(violation)
# 添加历史记录
add_history_entry(
video_id,
"add_violation",
user["user_id"],
{"violation_id": violation_id},
)
return AddViolationResponse(
violation_id=violation_id,
source="manual",
type=request.type,
content=request.content,
severity=request.severity,
)
@router.delete("/{video_id}/violations/{violation_id}", response_model=DeleteViolationResponse)
async def delete_violation(
video_id: str,
violation_id: str,
request: DeleteViolationRequest = DeleteViolationRequest(),
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}",
)
violations = video.get("violations", [])
violation = next((v for v in 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}",
)
video["violations"] = [v for v in violations if v["violation_id"] != violation_id]
# 添加历史记录
add_history_entry(
video_id,
"delete_violation",
user["user_id"],
{"violation_id": violation_id, "reason": request.delete_reason},
)
return DeleteViolationResponse(status="deleted")
@router.patch("/{video_id}/violations/{violation_id}", response_model=ModifyViolationResponse)
async def modify_violation(
video_id: str,
violation_id: str,
request: ModifyViolationRequest,
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}",
)
violations = video.get("violations", [])
violation = next((v for v in 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}",
)
violation["severity"] = request.severity
# 添加历史记录
add_history_entry(
video_id,
"modify_violation",
user["user_id"],
{"violation_id": violation_id, "new_severity": request.severity, "reason": request.modify_reason},
)
return ModifyViolationResponse(
violation_id=violation_id,
severity=request.severity,
)
@router.post("/{video_id}/appeal", response_model=AppealResponse, status_code=status.HTTP_201_CREATED)
async def submit_appeal(
video_id: str,
request: AppealRequest,
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)
user_data = get_user_by_id(user["user_id"])
# 检查申诉理由长度
if len(request.reason) < 10:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "申诉理由必须至少 10 个字符"},
)
# 检查申诉令牌
if not user_data or user_data.get("appeal_tokens", 0) <= 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "申诉令牌不足"},
)
video = VIDEOS.get(video_id)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video not found: {video_id}",
)
# 扣除令牌
update_user_tokens(user["user_id"], -1)
# 创建申诉
appeal_id = f"appeal_{uuid.uuid4().hex[:8]}"
APPEALS[appeal_id] = {
"appeal_id": appeal_id,
"video_id": video_id,
"user_id": user["user_id"],
"violation_ids": request.violation_ids,
"reason": request.reason,
"status": "pending",
"created_at": datetime.now().isoformat(),
}
# 添加历史记录
add_history_entry(
video_id,
"submit_appeal",
user["user_id"],
{"appeal_id": appeal_id},
)
return AppealResponse(
appeal_id=appeal_id,
status="pending",
)
@router.get("/{video_id}/history", response_model=ReviewHistoryResponse)
async def get_review_history(
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}",
)
history = REVIEW_HISTORY.get(video_id, [])
return ReviewHistoryResponse(history=history)
@router.get("/{video_id}")
async def get_review(
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 {
"video_id": video_id,
"status": video.get("status"),
"violations": video.get("violations", []),
}