""" 任务 API 实现完整的审核任务流程 """ from typing import Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database import get_db from app.models.user import User, UserRole from app.models.task import Task, TaskStage, TaskStatus from app.models.project import Project from app.models.organization import Brand, Agency, Creator from app.api.deps import ( get_current_user, get_current_agency, get_current_creator, get_current_brand, ) from app.schemas.task import ( TaskCreateRequest, TaskResponse, TaskListResponse, TaskSummary, ReviewTaskListResponse, TaskScriptUploadRequest, TaskVideoUploadRequest, TaskReviewRequest, AppealRequest, AppealCountRequest, AppealCountActionRequest, CreatorInfo, AgencyInfo, ProjectInfo, ) from app.services.task_service import ( create_task, get_task_by_id, check_task_permission, upload_script, upload_video, agency_review, brand_review, submit_appeal, increase_appeal_count, list_tasks_for_creator, list_tasks_for_agency, list_tasks_for_brand, list_pending_reviews_for_agency, list_pending_reviews_for_brand, ) from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision from app.services.message_service import create_message router = APIRouter(prefix="/tasks", tags=["任务"]) def _task_to_response(task: Task) -> TaskResponse: """将数据库模型转换为响应模型""" return TaskResponse( id=task.id, name=task.name, sequence=task.sequence, stage=task.stage, project=ProjectInfo( id=task.project.id, name=task.project.name, brand_name=task.project.brand.name if task.project.brand else None, ), agency=AgencyInfo( id=task.agency.id, name=task.agency.name, ), creator=CreatorInfo( id=task.creator.id, name=task.creator.name, avatar=task.creator.avatar, ), script_file_url=task.script_file_url, script_file_name=task.script_file_name, script_uploaded_at=task.script_uploaded_at, script_ai_score=task.script_ai_score, script_ai_result=task.script_ai_result, script_agency_status=task.script_agency_status, script_agency_comment=task.script_agency_comment, script_brand_status=task.script_brand_status, script_brand_comment=task.script_brand_comment, video_file_url=task.video_file_url, video_file_name=task.video_file_name, video_duration=task.video_duration, video_thumbnail_url=task.video_thumbnail_url, video_uploaded_at=task.video_uploaded_at, video_ai_score=task.video_ai_score, video_ai_result=task.video_ai_result, video_agency_status=task.video_agency_status, video_agency_comment=task.video_agency_comment, video_brand_status=task.video_brand_status, video_brand_comment=task.video_brand_comment, appeal_count=task.appeal_count, is_appeal=task.is_appeal, appeal_reason=task.appeal_reason, created_at=task.created_at, updated_at=task.updated_at, ) def _task_to_summary(task: Task) -> TaskSummary: """将任务转换为摘要""" return TaskSummary( id=task.id, name=task.name, stage=task.stage, creator_name=task.creator.name, creator_avatar=task.creator.avatar, project_name=task.project.name, is_appeal=task.is_appeal, appeal_reason=task.appeal_reason, created_at=task.created_at, updated_at=task.updated_at, ) # ===== 任务创建 ===== @router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED) async def create_new_task( request: TaskCreateRequest, agency: Agency = Depends(get_current_agency), db: AsyncSession = Depends(get_db), ): """ 创建任务(代理商操作) - 代理商为指定达人创建任务 - 同一项目同一达人可以创建多个任务 - 任务名称自动生成为 "宣传任务(N)" """ # 验证项目是否存在 result = await db.execute( select(Project).where(Project.id == request.project_id) ) project = result.scalar_one_or_none() if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在", ) # 验证达人是否存在 result = await db.execute( select(Creator).where(Creator.id == request.creator_id) ) creator = result.scalar_one_or_none() if not creator: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="达人不存在", ) # 创建任务 task = await create_task( db=db, project_id=request.project_id, agency_id=agency.id, creator_id=request.creator_id, name=request.name, ) await db.commit() # 重新加载关联 task = await get_task_by_id(db, task.id) # 创建消息 + SSE 通知达人有新任务 try: await create_message( db=db, user_id=creator.user_id, type="new_task", title="新任务分配", content=f"您有新的任务「{task.name}」,来自项目「{task.project.name}」", related_task_id=task.id, related_project_id=task.project.id, sender_name=agency.name, ) await db.commit() except Exception: pass try: await notify_new_task( task_id=task.id, creator_user_id=creator.user_id, task_name=task.name, project_name=task.project.name, ) except Exception: pass return _task_to_response(task) # ===== 任务查询 ===== @router.get("", response_model=TaskListResponse) async def list_tasks( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), stage: Optional[TaskStage] = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 查询任务列表 - 达人: 查看分配给自己的任务 - 代理商: 查看自己创建的任务 - 品牌方: 查看自己项目下的所有任务 """ if current_user.role == UserRole.CREATOR: result = await db.execute( select(Creator).where(Creator.user_id == current_user.id) ) creator = result.scalar_one_or_none() if not creator: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="达人信息不存在", ) tasks, total = await list_tasks_for_creator(db, creator.id, page, page_size, stage) elif current_user.role == UserRole.AGENCY: result = await db.execute( select(Agency).where(Agency.user_id == current_user.id) ) agency = result.scalar_one_or_none() if not agency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="代理商信息不存在", ) tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage) elif current_user.role == UserRole.BRAND: result = await db.execute( select(Brand).where(Brand.user_id == current_user.id) ) brand = result.scalar_one_or_none() if not brand: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="品牌方信息不存在", ) tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权限访问", ) return TaskListResponse( items=[_task_to_response(t) for t in tasks], total=total, page=page, page_size=page_size, ) @router.get("/pending", response_model=ReviewTaskListResponse) async def list_pending_reviews( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 获取待审核任务列表 - 代理商: 获取待代理商审核的任务 - 品牌方: 获取待品牌方终审的任务 """ if current_user.role == UserRole.AGENCY: result = await db.execute( select(Agency).where(Agency.user_id == current_user.id) ) agency = result.scalar_one_or_none() if not agency: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="代理商信息不存在", ) tasks, total = await list_pending_reviews_for_agency(db, agency.id, page, page_size) elif current_user.role == UserRole.BRAND: result = await db.execute( select(Brand).where(Brand.user_id == current_user.id) ) brand = result.scalar_one_or_none() if not brand: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="品牌方信息不存在", ) tasks, total = await list_pending_reviews_for_brand(db, brand.id, page, page_size) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅代理商和品牌方可查看待审核任务", ) return ReviewTaskListResponse( items=[_task_to_summary(t) for t in tasks], total=total, page=page, page_size=page_size, ) @router.get("/{task_id}", response_model=TaskResponse) async def get_task( task_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 查询任务详情 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) # 权限检查 has_permission = await check_task_permission(task, current_user, db) if not has_permission: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此任务", ) return _task_to_response(task) # ===== 文件上传 ===== @router.post("/{task_id}/script", response_model=TaskResponse) async def upload_task_script( task_id: str, request: TaskScriptUploadRequest, creator: Creator = Depends(get_current_creator), db: AsyncSession = Depends(get_db), ): """ 上传/更新脚本(达人操作) - 只能在 script_upload 阶段上传 - 上传后自动进入 AI 审核 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) if task.creator_id != creator.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只能上传自己任务的脚本", ) try: task = await upload_script( db=db, task=task, file_url=request.file_url, file_name=request.file_name, ) await db.commit() except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) # 重新加载关联 task = await get_task_by_id(db, task.id) # SSE 通知代理商脚本已上传 try: result = await db.execute( select(Agency).where(Agency.id == task.agency_id) ) agency_obj = result.scalar_one_or_none() if agency_obj: await notify_task_updated( task_id=task.id, user_ids=[agency_obj.user_id], data={"action": "script_uploaded", "stage": task.stage.value}, ) except Exception: pass return _task_to_response(task) @router.post("/{task_id}/video", response_model=TaskResponse) async def upload_task_video( task_id: str, request: TaskVideoUploadRequest, creator: Creator = Depends(get_current_creator), db: AsyncSession = Depends(get_db), ): """ 上传/更新视频(达人操作) - 只能在 video_upload 阶段上传 - 上传后自动进入 AI 审核 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) if task.creator_id != creator.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只能上传自己任务的视频", ) try: task = await upload_video( db=db, task=task, file_url=request.file_url, file_name=request.file_name, duration=request.duration, thumbnail_url=request.thumbnail_url, ) await db.commit() except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) # 重新加载关联 task = await get_task_by_id(db, task.id) # SSE 通知代理商视频已上传 try: result = await db.execute( select(Agency).where(Agency.id == task.agency_id) ) agency_obj = result.scalar_one_or_none() if agency_obj: await notify_task_updated( task_id=task.id, user_ids=[agency_obj.user_id], data={"action": "video_uploaded", "stage": task.stage.value}, ) except Exception: pass return _task_to_response(task) # ===== 审核操作 ===== @router.post("/{task_id}/script/review", response_model=TaskResponse) async def review_script( task_id: str, request: TaskReviewRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 审核脚本 - 代理商: 在 script_agency_review 阶段审核 - 品牌方: 在 script_brand_review 阶段审核 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) try: if current_user.role == UserRole.AGENCY: if task.stage != TaskStage.SCRIPT_AGENCY_REVIEW: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前阶段不在代理商审核中", ) result = await db.execute( select(Agency).where(Agency.user_id == current_user.id) ) agency = result.scalar_one_or_none() if not agency or task.agency_id != agency.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) task = await agency_review( db=db, task=task, reviewer_id=current_user.id, action=request.action, comment=request.comment, ) elif current_user.role == UserRole.BRAND: if task.stage != TaskStage.SCRIPT_BRAND_REVIEW: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前阶段不在品牌方审核中", ) result = await db.execute( select(Brand).where(Brand.user_id == current_user.id) ) brand = result.scalar_one_or_none() if not brand: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) # 验证任务属于该品牌 if task.project.brand_id != brand.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) # 品牌方不能使用 force_pass if request.action == "force_pass": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="品牌方不能使用强制通过", ) task = await brand_review( db=db, task=task, reviewer_id=current_user.id, action=request.action, comment=request.comment, ) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅代理商和品牌方可审核", ) await db.commit() except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) # 重新加载关联 task = await get_task_by_id(db, task.id) # 创建消息 + SSE 通知达人脚本审核结果 try: result = await db.execute( select(Creator).where(Creator.id == task.creator_id) ) creator_obj = result.scalar_one_or_none() if creator_obj: reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand" action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action) await create_message( db=db, user_id=creator_obj.user_id, type=request.action, title=f"脚本审核{action_text}", content=f"您的任务「{task.name}」脚本已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""), related_task_id=task.id, sender_name=current_user.name, ) await db.commit() await notify_review_decision( task_id=task.id, creator_user_id=creator_obj.user_id, review_type="script", reviewer_type=reviewer_type, action=request.action, comment=request.comment, ) await notify_task_updated( task_id=task.id, user_ids=[creator_obj.user_id], data={"action": f"script_{request.action}", "stage": task.stage.value}, ) except Exception: pass return _task_to_response(task) @router.post("/{task_id}/video/review", response_model=TaskResponse) async def review_video( task_id: str, request: TaskReviewRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ 审核视频 - 代理商: 在 video_agency_review 阶段审核 - 品牌方: 在 video_brand_review 阶段审核 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) try: if current_user.role == UserRole.AGENCY: if task.stage != TaskStage.VIDEO_AGENCY_REVIEW: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前阶段不在代理商审核中", ) result = await db.execute( select(Agency).where(Agency.user_id == current_user.id) ) agency = result.scalar_one_or_none() if not agency or task.agency_id != agency.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) task = await agency_review( db=db, task=task, reviewer_id=current_user.id, action=request.action, comment=request.comment, ) elif current_user.role == UserRole.BRAND: if task.stage != TaskStage.VIDEO_BRAND_REVIEW: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前阶段不在品牌方审核中", ) result = await db.execute( select(Brand).where(Brand.user_id == current_user.id) ) brand = result.scalar_one_or_none() if not brand: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) # 验证任务属于该品牌 if task.project.brand_id != brand.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="无权审核此任务", ) # 品牌方不能使用 force_pass if request.action == "force_pass": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="品牌方不能使用强制通过", ) task = await brand_review( db=db, task=task, reviewer_id=current_user.id, action=request.action, comment=request.comment, ) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅代理商和品牌方可审核", ) await db.commit() except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) # 重新加载关联 task = await get_task_by_id(db, task.id) # 创建消息 + SSE 通知达人视频审核结果 try: result = await db.execute( select(Creator).where(Creator.id == task.creator_id) ) creator_obj = result.scalar_one_or_none() if creator_obj: reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand" action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action) await create_message( db=db, user_id=creator_obj.user_id, type=request.action, title=f"视频审核{action_text}", content=f"您的任务「{task.name}」视频已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""), related_task_id=task.id, sender_name=current_user.name, ) await db.commit() await notify_review_decision( task_id=task.id, creator_user_id=creator_obj.user_id, review_type="video", reviewer_type=reviewer_type, action=request.action, comment=request.comment, ) await notify_task_updated( task_id=task.id, user_ids=[creator_obj.user_id], data={"action": f"video_{request.action}", "stage": task.stage.value}, ) except Exception: pass return _task_to_response(task) # ===== 申诉操作 ===== @router.post("/{task_id}/appeal", response_model=TaskResponse) async def submit_task_appeal( task_id: str, request: AppealRequest, creator: Creator = Depends(get_current_creator), db: AsyncSession = Depends(get_db), ): """ 提交申诉(达人操作) - 只能在 rejected 阶段申诉 - 需要有剩余申诉次数 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) if task.creator_id != creator.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只能申诉自己的任务", ) try: task = await submit_appeal( db=db, task=task, reason=request.reason, ) await db.commit() except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) # 重新加载关联 task = await get_task_by_id(db, task.id) # SSE 通知代理商有新申诉 try: result = await db.execute( select(Agency).where(Agency.id == task.agency_id) ) agency_obj = result.scalar_one_or_none() if agency_obj: await notify_task_updated( task_id=task.id, user_ids=[agency_obj.user_id], data={"action": "appeal_submitted", "stage": task.stage.value}, ) except Exception: pass return _task_to_response(task) @router.post("/{task_id}/appeal-count", response_model=TaskResponse) async def increase_task_appeal_count( task_id: str, agency: Agency = Depends(get_current_agency), db: AsyncSession = Depends(get_db), ): """ 增加申诉次数(代理商操作) - 每次调用增加 1 次申诉次数 """ task = await get_task_by_id(db, task_id) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在", ) if task.agency_id != agency.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只能操作自己的任务", ) task = await increase_appeal_count(db, task) await db.commit() # 重新加载关联 task = await get_task_by_id(db, task.id) return _task_to_response(task)