- 添加认证 API (登录/token验证) - 添加 Brief API (上传/解析/导入/冲突检测) - 添加视频 API (上传/断点续传/审核/违规/预览/重提交) - 添加审核 API (决策/批量审核/申诉/历史) - 实现基于角色的权限控制 - 更新集成测试,49 个测试全部通过 - 总体测试覆盖率 89.63% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
659 lines
18 KiB
Python
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", []),
|
|
}
|