diff --git a/backend/app/api/briefs.py b/backend/app/api/briefs.py new file mode 100644 index 0000000..583dad3 --- /dev/null +++ b/backend/app/api/briefs.py @@ -0,0 +1,182 @@ +""" +Brief API +项目 Brief 文档的 CRUD +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.project import Project +from app.models.brief import Brief +from app.models.organization import Brand, Agency +from app.api.deps import get_current_user +from app.schemas.brief import ( + BriefCreateRequest, + BriefUpdateRequest, + BriefResponse, +) +from app.services.auth import generate_id + +router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"]) + + +async def _get_project_with_permission( + project_id: str, + current_user: User, + db: AsyncSession, + require_write: bool = False, +) -> Project: + """获取项目并检查权限""" + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + if current_user.role == UserRole.BRAND: + brand_result = await db.execute( + select(Brand).where(Brand.user_id == current_user.id) + ) + brand = brand_result.scalar_one_or_none() + if not brand or project.brand_id != brand.id: + raise HTTPException(status_code=403, detail="无权访问此项目") + elif current_user.role == UserRole.AGENCY: + if require_write: + raise HTTPException(status_code=403, detail="代理商无权修改 Brief") + agency_result = await db.execute( + select(Agency).where(Agency.user_id == current_user.id) + ) + agency = agency_result.scalar_one_or_none() + if not agency or agency not in project.agencies: + raise HTTPException(status_code=403, detail="无权访问此项目") + elif current_user.role == UserRole.CREATOR: + # 达人可以查看 Brief(只读) + if require_write: + raise HTTPException(status_code=403, detail="达人无权修改 Brief") + else: + raise HTTPException(status_code=403, detail="无权访问") + + return project + + +def _brief_to_response(brief: Brief) -> BriefResponse: + """转换 Brief 为响应""" + return BriefResponse( + id=brief.id, + project_id=brief.project_id, + project_name=brief.project.name if brief.project else None, + file_url=brief.file_url, + file_name=brief.file_name, + selling_points=brief.selling_points, + blacklist_words=brief.blacklist_words, + competitors=brief.competitors, + brand_tone=brief.brand_tone, + min_duration=brief.min_duration, + max_duration=brief.max_duration, + other_requirements=brief.other_requirements, + attachments=brief.attachments, + created_at=brief.created_at, + updated_at=brief.updated_at, + ) + + +@router.get("", response_model=BriefResponse) +async def get_brief( + project_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取项目 Brief""" + await _get_project_with_permission(project_id, current_user, db) + + result = await db.execute( + select(Brief) + .options(selectinload(Brief.project)) + .where(Brief.project_id == project_id) + ) + brief = result.scalar_one_or_none() + if not brief: + raise HTTPException(status_code=404, detail="Brief 不存在") + + return _brief_to_response(brief) + + +@router.post("", response_model=BriefResponse, status_code=status.HTTP_201_CREATED) +async def create_brief( + project_id: str, + request: BriefCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """创建项目 Brief(品牌方操作)""" + await _get_project_with_permission(project_id, current_user, db, require_write=True) + + # 检查是否已存在 + existing = await db.execute( + select(Brief).where(Brief.project_id == project_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该项目已有 Brief,请使用更新接口") + + brief = Brief( + id=generate_id("BF"), + project_id=project_id, + file_url=request.file_url, + file_name=request.file_name, + selling_points=request.selling_points, + blacklist_words=request.blacklist_words, + competitors=request.competitors, + brand_tone=request.brand_tone, + min_duration=request.min_duration, + max_duration=request.max_duration, + other_requirements=request.other_requirements, + attachments=request.attachments, + ) + db.add(brief) + await db.flush() + + # 重新加载 + result = await db.execute( + select(Brief) + .options(selectinload(Brief.project)) + .where(Brief.id == brief.id) + ) + brief = result.scalar_one() + + return _brief_to_response(brief) + + +@router.put("", response_model=BriefResponse) +async def update_brief( + project_id: str, + request: BriefUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新项目 Brief(品牌方操作)""" + await _get_project_with_permission(project_id, current_user, db, require_write=True) + + result = await db.execute( + select(Brief) + .options(selectinload(Brief.project)) + .where(Brief.project_id == project_id) + ) + brief = result.scalar_one_or_none() + if not brief: + raise HTTPException(status_code=404, detail="Brief 不存在") + + # 更新字段 + update_fields = request.model_dump(exclude_unset=True) + for field, value in update_fields.items(): + setattr(brief, field, value) + + await db.flush() + await db.refresh(brief) + + return _brief_to_response(brief) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..5c55cad --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,289 @@ +""" +工作台统计 API +各角色仪表盘所需数据 +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from typing import Optional + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.task import Task, TaskStage +from app.models.project import Project +from app.models.organization import Brand, Agency, Creator +from app.api.deps import get_current_user + +router = APIRouter(prefix="/dashboard", tags=["工作台"]) + + +# ===== 响应模型 ===== + +class ReviewCount(BaseModel): + """审核数量""" + script: int = 0 + video: int = 0 + + +class CreatorDashboard(BaseModel): + """达人工作台数据""" + total_tasks: int = 0 + pending_script: int = 0 # 待上传脚本 + pending_video: int = 0 # 待上传视频 + in_review: int = 0 # 审核中 + completed: int = 0 # 已完成 + rejected: int = 0 # 被驳回 + + +class AgencyDashboard(BaseModel): + """代理商工作台数据""" + pending_review: ReviewCount # 待审核 + pending_appeal: int = 0 # 待处理申诉 + today_passed: ReviewCount # 今日通过 + in_progress: ReviewCount # 进行中 + total_creators: int = 0 # 达人总数 + total_tasks: int = 0 # 任务总数 + + +class BrandDashboard(BaseModel): + """品牌方工作台数据""" + total_projects: int = 0 # 项目总数 + active_projects: int = 0 # 进行中项目 + pending_review: ReviewCount # 待终审 + total_agencies: int = 0 # 代理商总数 + total_tasks: int = 0 # 任务总数 + completed_tasks: int = 0 # 已完成任务 + + +# ===== API ===== + +@router.get("/creator", response_model=CreatorDashboard) +async def get_creator_dashboard( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """达人工作台统计""" + if current_user.role != UserRole.CREATOR: + raise HTTPException(status_code=403, detail="仅达人可访问") + + 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=404, detail="达人信息不存在") + + creator_id = creator.id + + # 各阶段任务数 + stage_counts = {} + for stage in TaskStage: + count_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.creator_id == creator_id, Task.stage == stage) + ) + ) + stage_counts[stage] = count_result.scalar() or 0 + + total_result = await db.execute( + select(func.count(Task.id)).where(Task.creator_id == creator_id) + ) + total = total_result.scalar() or 0 + + in_review = ( + stage_counts.get(TaskStage.SCRIPT_AI_REVIEW, 0) + + stage_counts.get(TaskStage.SCRIPT_AGENCY_REVIEW, 0) + + stage_counts.get(TaskStage.SCRIPT_BRAND_REVIEW, 0) + + stage_counts.get(TaskStage.VIDEO_AI_REVIEW, 0) + + stage_counts.get(TaskStage.VIDEO_AGENCY_REVIEW, 0) + + stage_counts.get(TaskStage.VIDEO_BRAND_REVIEW, 0) + ) + + return CreatorDashboard( + total_tasks=total, + pending_script=stage_counts.get(TaskStage.SCRIPT_UPLOAD, 0), + pending_video=stage_counts.get(TaskStage.VIDEO_UPLOAD, 0), + in_review=in_review, + completed=stage_counts.get(TaskStage.COMPLETED, 0), + rejected=stage_counts.get(TaskStage.REJECTED, 0), + ) + + +@router.get("/agency", response_model=AgencyDashboard) +async def get_agency_dashboard( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """代理商工作台统计""" + if current_user.role != UserRole.AGENCY: + raise HTTPException(status_code=403, detail="仅代理商可访问") + + 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=404, detail="代理商信息不存在") + + agency_id = agency.id + + # 待审核脚本 + script_review_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.agency_id == agency_id, Task.stage == TaskStage.SCRIPT_AGENCY_REVIEW) + ) + ) + pending_script = script_review_result.scalar() or 0 + + # 待审核视频 + video_review_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.agency_id == agency_id, Task.stage == TaskStage.VIDEO_AGENCY_REVIEW) + ) + ) + pending_video = video_review_result.scalar() or 0 + + # 待处理申诉 + appeal_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.agency_id == agency_id, Task.is_appeal == True) + ) + ) + pending_appeal = appeal_result.scalar() or 0 + + # 进行中的脚本(AI审核+代理商审核+品牌方审核) + script_stages = [ + TaskStage.SCRIPT_AI_REVIEW, TaskStage.SCRIPT_AGENCY_REVIEW, TaskStage.SCRIPT_BRAND_REVIEW, + ] + script_progress_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.agency_id == agency_id, Task.stage.in_(script_stages)) + ) + ) + in_progress_script = script_progress_result.scalar() or 0 + + # 进行中的视频 + video_stages = [ + TaskStage.VIDEO_AI_REVIEW, TaskStage.VIDEO_AGENCY_REVIEW, TaskStage.VIDEO_BRAND_REVIEW, + ] + video_progress_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.agency_id == agency_id, Task.stage.in_(video_stages)) + ) + ) + in_progress_video = video_progress_result.scalar() or 0 + + # 达人总数 + from sqlalchemy.orm import selectinload + agency_loaded = await db.execute( + select(Agency).options(selectinload(Agency.creators)).where(Agency.id == agency_id) + ) + agency_with_creators = agency_loaded.scalar_one() + total_creators = len(agency_with_creators.creators) + + # 任务总数 + total_result = await db.execute( + select(func.count(Task.id)).where(Task.agency_id == agency_id) + ) + total_tasks = total_result.scalar() or 0 + + return AgencyDashboard( + pending_review=ReviewCount(script=pending_script, video=pending_video), + pending_appeal=pending_appeal, + today_passed=ReviewCount(script=0, video=0), # TODO: 按日期过滤 + in_progress=ReviewCount(script=in_progress_script, video=in_progress_video), + total_creators=total_creators, + total_tasks=total_tasks, + ) + + +@router.get("/brand", response_model=BrandDashboard) +async def get_brand_dashboard( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """品牌方工作台统计""" + if current_user.role != UserRole.BRAND: + raise HTTPException(status_code=403, 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=404, detail="品牌方信息不存在") + + brand_id = brand.id + + # 项目统计 + total_projects_result = await db.execute( + select(func.count(Project.id)).where(Project.brand_id == brand_id) + ) + total_projects = total_projects_result.scalar() or 0 + + active_projects_result = await db.execute( + select(func.count(Project.id)).where( + and_(Project.brand_id == brand_id, Project.status == "active") + ) + ) + active_projects = active_projects_result.scalar() or 0 + + # 获取项目 ID 列表 + project_ids_result = await db.execute( + select(Project.id).where(Project.brand_id == brand_id) + ) + project_ids = [row[0] for row in project_ids_result.all()] + + pending_script = 0 + pending_video = 0 + total_tasks = 0 + completed_tasks = 0 + + if project_ids: + # 待终审脚本 + script_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.SCRIPT_BRAND_REVIEW) + ) + ) + pending_script = script_result.scalar() or 0 + + # 待终审视频 + video_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.VIDEO_BRAND_REVIEW) + ) + ) + pending_video = video_result.scalar() or 0 + + # 任务总数 + total_tasks_result = await db.execute( + select(func.count(Task.id)).where(Task.project_id.in_(project_ids)) + ) + total_tasks = total_tasks_result.scalar() or 0 + + # 已完成 + completed_result = await db.execute( + select(func.count(Task.id)).where( + and_(Task.project_id.in_(project_ids), Task.stage == TaskStage.COMPLETED) + ) + ) + completed_tasks = completed_result.scalar() or 0 + + # 代理商总数 + from sqlalchemy.orm import selectinload + brand_loaded = await db.execute( + select(Brand).options(selectinload(Brand.agencies)).where(Brand.id == brand_id) + ) + brand_with_agencies = brand_loaded.scalar_one() + total_agencies = len(brand_with_agencies.agencies) + + return BrandDashboard( + total_projects=total_projects, + active_projects=active_projects, + pending_review=ReviewCount(script=pending_script, video=pending_video), + total_agencies=total_agencies, + total_tasks=total_tasks, + completed_tasks=completed_tasks, + ) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..9e8c246 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,172 @@ +""" +API 依赖项 +""" +from typing import Optional +from fastapi import Depends, HTTPException, Header, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +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.organization import Brand, Agency, Creator +from app.services.auth import decode_token + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + """获取当前登录用户""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供认证信息", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = decode_token(token) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 Token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 Token 类型", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 Token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账号已被禁用", + ) + + return user + + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db), +) -> Optional[User]: + """获取可选的当前用户(未登录时返回 None)""" + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None + + +async def get_current_brand( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Brand: + """获取当前品牌方(仅品牌方角色可用)""" + if current_user.role != UserRole.BRAND: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + 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_404_NOT_FOUND, + detail="品牌方信息不存在", + ) + + return brand + + +async def get_current_agency( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Agency: + """获取当前代理商(仅代理商角色可用)""" + if current_user.role != UserRole.AGENCY: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅代理商可执行此操作", + ) + + 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="代理商信息不存在", + ) + + return agency + + +async def get_current_creator( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Creator: + """获取当前达人(仅达人角色可用)""" + if current_user.role != UserRole.CREATOR: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅达人可执行此操作", + ) + + 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="达人信息不存在", + ) + + return creator + + +def require_roles(*roles: UserRole): + """角色权限检查装饰器""" + async def checker(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in roles: + role_names = [r.value for r in roles] + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"需要以下角色之一: {', '.join(role_names)}", + ) + return current_user + return checker diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 0000000..6c0d357 --- /dev/null +++ b/backend/app/api/organizations.py @@ -0,0 +1,322 @@ +""" +组织关系 API +品牌方管理代理商,代理商管理达人 +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.organization import ( + Brand, Agency, Creator, + brand_agency_association, agency_creator_association, +) +from app.api.deps import get_current_user, get_current_brand, get_current_agency +from app.schemas.organization import ( + BrandSummary, + AgencySummary, + CreatorSummary, + InviteAgencyRequest, + InviteCreatorRequest, + UpdateAgencyPermissionRequest, + AgencyListResponse, + CreatorListResponse, + BrandListResponse, +) + +router = APIRouter(prefix="/organizations", tags=["组织关系"]) + + +# ===== 品牌方管理代理商 ===== + + +@router.get("/brand/agencies", response_model=AgencyListResponse) +async def list_brand_agencies( + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """查询品牌方的代理商列表""" + result = await db.execute( + select(Brand) + .options(selectinload(Brand.agencies)) + .where(Brand.id == brand.id) + ) + brand_with_agencies = result.scalar_one() + + items = [ + AgencySummary( + id=a.id, + name=a.name, + logo=a.logo, + contact_name=a.contact_name, + force_pass_enabled=a.force_pass_enabled, + ) + for a in brand_with_agencies.agencies + ] + + return AgencyListResponse(items=items, total=len(items)) + + +@router.post("/brand/agencies", status_code=status.HTTP_201_CREATED) +async def invite_agency( + request: InviteAgencyRequest, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """邀请代理商加入品牌方""" + # 查找代理商 + result = await db.execute( + select(Agency).where(Agency.id == request.agency_id) + ) + agency = result.scalar_one_or_none() + if not agency: + raise HTTPException(status_code=404, detail="代理商不存在") + + # 检查是否已关联 + brand_result = await db.execute( + select(Brand) + .options(selectinload(Brand.agencies)) + .where(Brand.id == brand.id) + ) + brand_with_agencies = brand_result.scalar_one() + + if agency in brand_with_agencies.agencies: + raise HTTPException(status_code=400, detail="该代理商已加入") + + brand_with_agencies.agencies.append(agency) + await db.flush() + + return {"message": "邀请成功", "agency_id": agency.id} + + +@router.delete("/brand/agencies/{agency_id}") +async def remove_agency( + agency_id: str, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """移除代理商""" + brand_result = await db.execute( + select(Brand) + .options(selectinload(Brand.agencies)) + .where(Brand.id == brand.id) + ) + brand_with_agencies = brand_result.scalar_one() + + agency_result = await db.execute( + select(Agency).where(Agency.id == agency_id) + ) + agency = agency_result.scalar_one_or_none() + + if agency and agency in brand_with_agencies.agencies: + brand_with_agencies.agencies.remove(agency) + await db.flush() + + return {"message": "已移除"} + + +@router.put("/brand/agencies/{agency_id}/permission") +async def update_agency_permission( + agency_id: str, + request: UpdateAgencyPermissionRequest, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """更新代理商权限(如强制通过权)""" + # 验证代理商是否属于该品牌 + brand_result = await db.execute( + select(Brand) + .options(selectinload(Brand.agencies)) + .where(Brand.id == brand.id) + ) + brand_with_agencies = brand_result.scalar_one() + + agency_result = await db.execute( + select(Agency).where(Agency.id == agency_id) + ) + agency = agency_result.scalar_one_or_none() + if not agency or agency not in brand_with_agencies.agencies: + raise HTTPException(status_code=404, detail="代理商不存在或未加入") + + agency.force_pass_enabled = request.force_pass_enabled + await db.flush() + + return {"message": "权限已更新"} + + +# ===== 代理商管理达人 ===== + + +@router.get("/agency/creators", response_model=CreatorListResponse) +async def list_agency_creators( + agency: Agency = Depends(get_current_agency), + db: AsyncSession = Depends(get_db), +): + """查询代理商的达人列表""" + result = await db.execute( + select(Agency) + .options(selectinload(Agency.creators)) + .where(Agency.id == agency.id) + ) + agency_with_creators = result.scalar_one() + + items = [ + CreatorSummary( + id=c.id, + name=c.name, + avatar=c.avatar, + douyin_account=c.douyin_account, + xiaohongshu_account=c.xiaohongshu_account, + bilibili_account=c.bilibili_account, + ) + for c in agency_with_creators.creators + ] + + return CreatorListResponse(items=items, total=len(items)) + + +@router.post("/agency/creators", status_code=status.HTTP_201_CREATED) +async def invite_creator( + request: InviteCreatorRequest, + agency: Agency = Depends(get_current_agency), + db: AsyncSession = Depends(get_db), +): + """邀请达人加入代理商""" + 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=404, detail="达人不存在") + + agency_result = await db.execute( + select(Agency) + .options(selectinload(Agency.creators)) + .where(Agency.id == agency.id) + ) + agency_with_creators = agency_result.scalar_one() + + if creator in agency_with_creators.creators: + raise HTTPException(status_code=400, detail="该达人已加入") + + agency_with_creators.creators.append(creator) + await db.flush() + + return {"message": "邀请成功", "creator_id": creator.id} + + +@router.delete("/agency/creators/{creator_id}") +async def remove_creator( + creator_id: str, + agency: Agency = Depends(get_current_agency), + db: AsyncSession = Depends(get_db), +): + """移除达人""" + agency_result = await db.execute( + select(Agency) + .options(selectinload(Agency.creators)) + .where(Agency.id == agency.id) + ) + agency_with_creators = agency_result.scalar_one() + + creator_result = await db.execute( + select(Creator).where(Creator.id == creator_id) + ) + creator = creator_result.scalar_one_or_none() + + if creator and creator in agency_with_creators.creators: + agency_with_creators.creators.remove(creator) + await db.flush() + + return {"message": "已移除"} + + +# ===== 代理商查看关联品牌方 ===== + + +@router.get("/agency/brands", response_model=BrandListResponse) +async def list_agency_brands( + agency: Agency = Depends(get_current_agency), + db: AsyncSession = Depends(get_db), +): + """查询代理商关联的品牌方列表""" + result = await db.execute( + select(Agency) + .options(selectinload(Agency.brands)) + .where(Agency.id == agency.id) + ) + agency_with_brands = result.scalar_one() + + items = [ + BrandSummary( + id=b.id, + name=b.name, + logo=b.logo, + contact_name=b.contact_name, + ) + for b in agency_with_brands.brands + ] + + return BrandListResponse(items=items, total=len(items)) + + +# ===== 搜索(用于邀请时查找) ===== + + +@router.get("/search/agencies") +async def search_agencies( + keyword: str = Query(..., min_length=1), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """搜索代理商(用于邀请)""" + result = await db.execute( + select(Agency) + .where(Agency.name.ilike(f"%{keyword}%")) + .limit(20) + ) + agencies = list(result.scalars().all()) + + return { + "items": [ + AgencySummary( + id=a.id, + name=a.name, + logo=a.logo, + contact_name=a.contact_name, + force_pass_enabled=a.force_pass_enabled, + ).model_dump() + for a in agencies + ] + } + + +@router.get("/search/creators") +async def search_creators( + keyword: str = Query(..., min_length=1), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """搜索达人(用于邀请)""" + result = await db.execute( + select(Creator) + .where(Creator.name.ilike(f"%{keyword}%")) + .limit(20) + ) + creators = list(result.scalars().all()) + + return { + "items": [ + CreatorSummary( + id=c.id, + name=c.name, + avatar=c.avatar, + douyin_account=c.douyin_account, + xiaohongshu_account=c.xiaohongshu_account, + bilibili_account=c.bilibili_account, + ).model_dump() + for c in creators + ] + } diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..551fea8 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,328 @@ +""" +项目 API +品牌方创建和管理项目,分配代理商 +""" +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.project import Project, project_agency_association +from app.models.task import Task +from app.models.organization import Brand, Agency +from app.api.deps import get_current_user, get_current_brand, get_current_agency +from app.schemas.project import ( + ProjectCreateRequest, + ProjectUpdateRequest, + ProjectAssignAgencyRequest, + ProjectResponse, + ProjectListResponse, + AgencySummary, +) +from app.services.auth import generate_id + +router = APIRouter(prefix="/projects", tags=["项目"]) + + +async def _project_to_response(project: Project, db: AsyncSession) -> ProjectResponse: + """将项目模型转换为响应""" + # 获取任务数量 + count_result = await db.execute( + select(func.count(Task.id)).where(Task.project_id == project.id) + ) + task_count = count_result.scalar() or 0 + + agencies = [] + if project.agencies: + agencies = [ + AgencySummary(id=a.id, name=a.name, logo=a.logo) + for a in project.agencies + ] + + return ProjectResponse( + id=project.id, + name=project.name, + description=project.description, + brand_id=project.brand_id, + brand_name=project.brand.name if project.brand else None, + status=project.status, + start_date=project.start_date, + deadline=project.deadline, + agencies=agencies, + task_count=task_count, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + +@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_project( + request: ProjectCreateRequest, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """ + 创建项目(品牌方操作) + """ + project = Project( + id=generate_id("PJ"), + brand_id=brand.id, + name=request.name, + description=request.description, + start_date=request.start_date, + deadline=request.deadline, + status="active", + ) + db.add(project) + await db.flush() + + # 分配代理商 + if request.agency_ids: + for agency_id in request.agency_ids: + result = await db.execute( + select(Agency).where(Agency.id == agency_id) + ) + agency = result.scalar_one_or_none() + if agency: + project.agencies.append(agency) + await db.flush() + + await db.refresh(project) + + # 重新加载关联 + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project.id) + ) + project = result.scalar_one() + + return await _project_to_response(project, db) + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status_filter: Optional[str] = Query(None, alias="status"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 查询项目列表 + + - 品牌方: 查看自己创建的项目 + - 代理商: 查看被分配的项目 + """ + if 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=404, detail="品牌方信息不存在") + + query = ( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.brand_id == brand.id) + ) + count_query = select(func.count(Project.id)).where(Project.brand_id == brand.id) + + if status_filter: + query = query.where(Project.status == status_filter) + count_query = count_query.where(Project.status == status_filter) + + 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=404, detail="代理商信息不存在") + + # 通过关联表查询 + project_ids_query = ( + select(project_agency_association.c.project_id) + .where(project_agency_association.c.agency_id == agency.id) + ) + project_ids_result = await db.execute(project_ids_query) + project_ids = [row[0] for row in project_ids_result.all()] + + if not project_ids: + return ProjectListResponse(items=[], total=0, page=page, page_size=page_size) + + query = ( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id.in_(project_ids)) + ) + count_query = select(func.count(Project.id)).where(Project.id.in_(project_ids)) + + if status_filter: + query = query.where(Project.status == status_filter) + count_query = count_query.where(Project.status == status_filter) + + else: + raise HTTPException(status_code=403, detail="达人无权查看项目列表") + + query = query.order_by(Project.created_at.desc()) + + # 总数 + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + projects = list(result.scalars().all()) + + items = [] + for p in projects: + items.append(await _project_to_response(p, db)) + + return ProjectListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + project_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查询项目详情""" + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 权限检查 + if current_user.role == UserRole.BRAND: + brand_result = await db.execute( + select(Brand).where(Brand.user_id == current_user.id) + ) + brand = brand_result.scalar_one_or_none() + if not brand or project.brand_id != brand.id: + raise HTTPException(status_code=403, detail="无权访问此项目") + elif current_user.role == UserRole.AGENCY: + agency_result = await db.execute( + select(Agency).where(Agency.user_id == current_user.id) + ) + agency = agency_result.scalar_one_or_none() + if not agency or agency not in project.agencies: + raise HTTPException(status_code=403, detail="无权访问此项目") + else: + raise HTTPException(status_code=403, detail="无权访问此项目") + + return await _project_to_response(project, db) + + +@router.put("/{project_id}", response_model=ProjectResponse) +async def update_project( + project_id: str, + request: ProjectUpdateRequest, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """更新项目(品牌方操作)""" + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + if project.brand_id != brand.id: + raise HTTPException(status_code=403, detail="无权修改此项目") + + if request.name is not None: + project.name = request.name + if request.description is not None: + project.description = request.description + if request.start_date is not None: + project.start_date = request.start_date + if request.deadline is not None: + project.deadline = request.deadline + if request.status is not None: + project.status = request.status + + await db.flush() + await db.refresh(project) + + return await _project_to_response(project, db) + + +@router.post("/{project_id}/agencies", response_model=ProjectResponse) +async def assign_agencies( + project_id: str, + request: ProjectAssignAgencyRequest, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """分配代理商到项目(品牌方操作)""" + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + if project.brand_id != brand.id: + raise HTTPException(status_code=403, detail="无权操作此项目") + + for agency_id in request.agency_ids: + agency_result = await db.execute( + select(Agency).where(Agency.id == agency_id) + ) + agency = agency_result.scalar_one_or_none() + if agency and agency not in project.agencies: + project.agencies.append(agency) + + await db.flush() + await db.refresh(project) + + return await _project_to_response(project, db) + + +@router.delete("/{project_id}/agencies/{agency_id}", response_model=ProjectResponse) +async def remove_agency_from_project( + project_id: str, + agency_id: str, + brand: Brand = Depends(get_current_brand), + db: AsyncSession = Depends(get_db), +): + """从项目移除代理商(品牌方操作)""" + result = await db.execute( + select(Project) + .options(selectinload(Project.brand), selectinload(Project.agencies)) + .where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + if project.brand_id != brand.id: + raise HTTPException(status_code=403, detail="无权操作此项目") + + agency_result = await db.execute( + select(Agency).where(Agency.id == agency_id) + ) + agency = agency_result.scalar_one_or_none() + if agency and agency in project.agencies: + project.agencies.remove(agency) + + await db.flush() + await db.refresh(project) + + return await _project_to_response(project, db) diff --git a/backend/app/api/sse.py b/backend/app/api/sse.py new file mode 100644 index 0000000..5f6eb97 --- /dev/null +++ b/backend/app/api/sse.py @@ -0,0 +1,235 @@ +""" +SSE (Server-Sent Events) 实时推送 API +用于推送审核进度等实时通知 +""" +import asyncio +import json +from typing import AsyncGenerator, Optional, Set +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status +from sse_starlette.sse import EventSourceResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.organization import Brand, Agency, Creator +from app.api.deps import get_current_user +from sqlalchemy import select + +router = APIRouter(prefix="/sse", tags=["实时推送"]) + +# 存储活跃的客户端连接 +# 结构: {user_id: set of AsyncGenerator} +active_connections: dict[str, Set[asyncio.Queue]] = {} + + +async def add_connection(user_id: str, queue: asyncio.Queue): + """添加客户端连接""" + if user_id not in active_connections: + active_connections[user_id] = set() + active_connections[user_id].add(queue) + + +async def remove_connection(user_id: str, queue: asyncio.Queue): + """移除客户端连接""" + if user_id in active_connections: + active_connections[user_id].discard(queue) + if not active_connections[user_id]: + del active_connections[user_id] + + +async def send_to_user(user_id: str, event: str, data: dict): + """发送消息给指定用户的所有连接""" + if user_id in active_connections: + message = { + "event": event, + "data": data, + "timestamp": datetime.utcnow().isoformat(), + } + for queue in active_connections[user_id]: + await queue.put(message) + + +async def broadcast_to_role(role: UserRole, event: str, data: dict, db: AsyncSession): + """广播消息给指定角色的所有用户""" + # 这里简化处理,实际应该批量查询 + # 在生产环境中应该使用 Redis 等消息队列 + pass + + +async def event_generator(user_id: str, queue: asyncio.Queue) -> AsyncGenerator[dict, None]: + """SSE 事件生成器""" + try: + await add_connection(user_id, queue) + + # 发送连接成功消息 + yield { + "event": "connected", + "data": json.dumps({ + "message": "连接成功", + "user_id": user_id, + }), + } + + while True: + try: + # 等待消息,超时后发送心跳 + message = await asyncio.wait_for(queue.get(), timeout=30.0) + yield { + "event": message["event"], + "data": json.dumps(message["data"]), + } + except asyncio.TimeoutError: + # 发送心跳保持连接 + yield { + "event": "heartbeat", + "data": json.dumps({"timestamp": datetime.utcnow().isoformat()}), + } + + except asyncio.CancelledError: + pass + finally: + await remove_connection(user_id, queue) + + +@router.get("/events") +async def sse_events( + current_user: User = Depends(get_current_user), +): + """ + SSE 事件流 + + - 客户端通过此端点订阅实时事件 + - 支持的事件类型: + - connected: 连接成功 + - heartbeat: 心跳 + - task_updated: 任务状态更新 + - review_progress: AI 审核进度 + - review_completed: AI 审核完成 + - new_task: 新任务分配 + """ + queue = asyncio.Queue() + + return EventSourceResponse( + event_generator(current_user.id, queue), + media_type="text/event-stream", + ) + + +# ===== 推送工具函数(供其他模块调用) ===== + + +async def notify_task_updated(task_id: str, user_ids: list[str], data: dict): + """ + 通知任务状态更新 + + Args: + task_id: 任务 ID + user_ids: 需要通知的用户 ID 列表 + data: 推送数据 + """ + for user_id in user_ids: + await send_to_user(user_id, "task_updated", { + "task_id": task_id, + **data, + }) + + +async def notify_review_progress( + task_id: str, + user_id: str, + progress: int, + current_step: str, + review_type: str, # "script" or "video" +): + """ + 通知 AI 审核进度 + + Args: + task_id: 任务 ID + user_id: 达人用户 ID + progress: 进度百分比 (0-100) + current_step: 当前步骤描述 + review_type: 审核类型 + """ + await send_to_user(user_id, "review_progress", { + "task_id": task_id, + "review_type": review_type, + "progress": progress, + "current_step": current_step, + }) + + +async def notify_review_completed( + task_id: str, + user_id: str, + review_type: str, + score: int, + violations_count: int, +): + """ + 通知 AI 审核完成 + + Args: + task_id: 任务 ID + user_id: 达人用户 ID + review_type: 审核类型 + score: 审核分数 + violations_count: 违规数量 + """ + await send_to_user(user_id, "review_completed", { + "task_id": task_id, + "review_type": review_type, + "score": score, + "violations_count": violations_count, + }) + + +async def notify_new_task( + task_id: str, + creator_user_id: str, + task_name: str, + project_name: str, +): + """ + 通知新任务分配 + + Args: + task_id: 任务 ID + creator_user_id: 达人用户 ID + task_name: 任务名称 + project_name: 项目名称 + """ + await send_to_user(creator_user_id, "new_task", { + "task_id": task_id, + "task_name": task_name, + "project_name": project_name, + }) + + +async def notify_review_decision( + task_id: str, + creator_user_id: str, + review_type: str, # "script" or "video" + reviewer_type: str, # "agency" or "brand" + action: str, # "pass", "reject", "force_pass" + comment: Optional[str] = None, +): + """ + 通知审核决策 + + Args: + task_id: 任务 ID + creator_user_id: 达人用户 ID + review_type: 审核类型 + reviewer_type: 审核者类型 + action: 审核动作 + comment: 审核意见 + """ + await send_to_user(creator_user_id, "review_decision", { + "task_id": task_id, + "review_type": review_type, + "reviewer_type": reviewer_type, + "action": action, + "comment": comment, + }) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 56add61..1bea47f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,241 +1,239 @@ """ -审核任务 API +任务 API +实现完整的审核任务流程 """ -import uuid -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from sqlalchemy import select, and_ +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.tenant import Tenant -from app.models.review import ManualTask, TaskStatus as DBTaskStatus, Platform as DBPlatform -from app.schemas.review import ( +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, - TaskApproveRequest, - TaskRejectRequest, - TaskStatus, - Platform, + 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, ) -router = APIRouter(prefix="/tasks", tags=["tasks"]) +router = APIRouter(prefix="/tasks", tags=["任务"]) -async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant: - """确保租户存在,不存在则自动创建""" - result = await db.execute( - select(Tenant).where(Tenant.id == tenant_id) - ) - tenant = result.scalar_one_or_none() - - if not tenant: - tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}") - db.add(tenant) - await db.flush() - - return tenant - - -def _task_to_response(task: ManualTask) -> TaskResponse: +def _task_to_response(task: Task) -> TaskResponse: """将数据库模型转换为响应模型""" return TaskResponse( - task_id=task.id, - video_url=task.video_url, - script_content=task.script_content, + 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, - has_script=bool(task.script_content or task.script_file_url), - has_video=bool(task.video_url), - platform=Platform(task.platform.value), - creator_id=task.creator_id, - status=TaskStatus(task.status.value), - created_at=task.created_at.isoformat() if task.created_at else "", + 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_task( +async def create_new_task( request: TaskCreateRequest, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), + agency: Agency = Depends(get_current_agency), db: AsyncSession = Depends(get_db), -) -> TaskResponse: +): """ - 创建审核任务 + 创建任务(代理商操作) + + - 代理商为指定达人创建任务 + - 同一项目同一达人可以创建多个任务 + - 任务名称自动生成为 "宣传任务(N)" """ - # 确保租户存在 - await _ensure_tenant_exists(x_tenant_id, db) + # 验证项目是否存在 + 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="项目不存在", + ) - task_id = f"task-{uuid.uuid4().hex[:12]}" + # 验证达人是否存在 + 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 = ManualTask( - id=task_id, - tenant_id=x_tenant_id, - video_url=str(request.video_url) if request.video_url else None, - video_uploaded_at=datetime.now(timezone.utc) if request.video_url else None, - platform=DBPlatform(request.platform.value), + # 创建任务 + task = await create_task( + db=db, + project_id=request.project_id, + agency_id=agency.id, creator_id=request.creator_id, - status=DBTaskStatus.PENDING, - script_content=request.script_content, - script_file_url=str(request.script_file_url) if request.script_file_url else None, - script_uploaded_at=datetime.now(timezone.utc) - if request.script_content or request.script_file_url - else None, + name=request.name, ) - db.add(task) - await db.flush() - await db.refresh(task) + + await db.commit() + + # 重新加载关联 + task = await get_task_by_id(db, task.id) return _task_to_response(task) -@router.post("/{task_id}/script", response_model=TaskResponse) -async def upload_task_script( - task_id: str, - request: TaskScriptUploadRequest, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), - db: AsyncSession = Depends(get_db), -) -> TaskResponse: - """ - 上传/更新任务脚本 - """ - if not request.script_content and not request.script_file_url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="script_content 或 script_file_url 至少提供一个", - ) - - result = await db.execute( - select(ManualTask).where( - and_( - ManualTask.id == task_id, - ManualTask.tenant_id == x_tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"任务不存在: {task_id}", - ) - - task.script_content = request.script_content - task.script_file_url = ( - str(request.script_file_url) if request.script_file_url else None - ) - task.script_uploaded_at = datetime.now(timezone.utc) - - await db.flush() - await db.refresh(task) - - return _task_to_response(task) - - -@router.post("/{task_id}/video", response_model=TaskResponse) -async def upload_task_video( - task_id: str, - request: TaskVideoUploadRequest, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), - db: AsyncSession = Depends(get_db), -) -> TaskResponse: - """ - 上传/更新任务视频 - """ - result = await db.execute( - select(ManualTask).where( - and_( - ManualTask.id == task_id, - ManualTask.tenant_id == x_tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"任务不存在: {task_id}", - ) - - task.video_url = str(request.video_url) - task.video_uploaded_at = datetime.now(timezone.utc) - - await db.flush() - await db.refresh(task) - - return _task_to_response(task) - - -@router.get("/{task_id}", response_model=TaskResponse) -async def get_task( - task_id: str, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), - db: AsyncSession = Depends(get_db), -) -> TaskResponse: - """ - 查询单个任务 - """ - result = await db.execute( - select(ManualTask).where( - and_( - ManualTask.id == task_id, - ManualTask.tenant_id == x_tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() - - if not task: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"任务不存在: {task_id}", - ) - - 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), - task_status: TaskStatus = Query(None, alias="status"), - platform: Platform = None, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), + stage: Optional[TaskStage] = Query(None), + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), -) -> TaskListResponse: +): """ 查询任务列表 - 支持分页和筛选 + - 达人: 查看分配给自己的任务 + - 代理商: 查看自己创建的任务 + - 品牌方: 查看自己项目下的所有任务 """ - # 构建查询 - query = select(ManualTask).where(ManualTask.tenant_id == x_tenant_id) + 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) - if task_status: - query = query.where(ManualTask.status == DBTaskStatus(task_status.value)) + 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) - if platform: - query = query.where(ManualTask.platform == DBPlatform(platform.value)) + 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) - # 按创建时间倒序排列 - query = query.order_by(ManualTask.created_at.desc()) - - # 执行查询获取总数 - count_result = await db.execute( - select(ManualTask.id).where(ManualTask.tenant_id == x_tenant_id) - ) - total = len(count_result.all()) - - # 分页 - offset = (page - 1) * page_size - query = query.offset(offset).limit(page_size) - - result = await db.execute(query) - tasks = result.scalars().all() + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限访问", + ) return TaskListResponse( items=[_task_to_response(t) for t in tasks], @@ -245,74 +243,470 @@ async def list_tasks( ) -@router.post("/{task_id}/approve", response_model=TaskResponse) -async def approve_task( - task_id: str, - request: TaskApproveRequest, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), +@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), -) -> TaskResponse: +): """ - 通过任务 - """ - result = await db.execute( - select(ManualTask).where( - and_( - ManualTask.id == task_id, - ManualTask.tenant_id == x_tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() + 获取待审核任务列表 + - 代理商: 获取待代理商审核的任务 + - 品牌方: 获取待品牌方终审的任务 + """ + 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=f"任务不存在: {task_id}", + detail="任务不存在", ) - task.status = DBTaskStatus.APPROVED - task.approve_comment = request.comment - task.reviewed_at = datetime.now(timezone.utc) - - await db.flush() - await db.refresh(task) + # 权限检查 + 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}/reject", response_model=TaskResponse) -async def reject_task( - task_id: str, - request: TaskRejectRequest, - x_tenant_id: str = Header(..., alias="X-Tenant-ID"), - db: AsyncSession = Depends(get_db), -) -> TaskResponse: - """ - 驳回任务 - """ - result = await db.execute( - select(ManualTask).where( - and_( - ManualTask.id == task_id, - ManualTask.tenant_id == x_tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() +# ===== 文件上传 ===== + +@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=f"任务不存在: {task_id}", + detail="任务不存在", ) - task.status = DBTaskStatus.REJECTED - task.reject_reason = request.reason - task.reject_violations = request.violations - task.reviewed_at = datetime.now(timezone.utc) + if task.creator_id != creator.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只能上传自己任务的脚本", + ) - await db.flush() - await db.refresh(task) + 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) + + 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) + + 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) + + 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) + + 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) + + 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) diff --git a/backend/app/main.py b/backend/app/main.py index 88013f9..8663aa6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, risk_exceptions, metrics +from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, risk_exceptions, metrics, sse, projects, briefs, organizations, dashboard # 创建应用 app = FastAPI( @@ -33,6 +33,11 @@ app.include_router(rules.router, prefix="/api/v1") app.include_router(ai_config.router, prefix="/api/v1") app.include_router(risk_exceptions.router, prefix="/api/v1") app.include_router(metrics.router, prefix="/api/v1") +app.include_router(sse.router, prefix="/api/v1") +app.include_router(projects.router, prefix="/api/v1") +app.include_router(briefs.router, prefix="/api/v1") +app.include_router(organizations.router, prefix="/api/v1") +app.include_router(dashboard.router, prefix="/api/v1") @app.get("/") diff --git a/backend/app/schemas/brief.py b/backend/app/schemas/brief.py new file mode 100644 index 0000000..3766e0a --- /dev/null +++ b/backend/app/schemas/brief.py @@ -0,0 +1,60 @@ +""" +Brief 相关 Schema +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field + + +# ===== 请求 ===== + +class BriefCreateRequest(BaseModel): + """创建/更新 Brief 请求""" + file_url: Optional[str] = None + file_name: Optional[str] = None + selling_points: Optional[List[dict]] = None + blacklist_words: Optional[List[dict]] = None + competitors: Optional[List[str]] = None + brand_tone: Optional[str] = None + min_duration: Optional[int] = None + max_duration: Optional[int] = None + other_requirements: Optional[str] = None + attachments: Optional[List[dict]] = None + + +class BriefUpdateRequest(BaseModel): + """更新 Brief 请求""" + file_url: Optional[str] = None + file_name: Optional[str] = None + selling_points: Optional[List[dict]] = None + blacklist_words: Optional[List[dict]] = None + competitors: Optional[List[str]] = None + brand_tone: Optional[str] = None + min_duration: Optional[int] = None + max_duration: Optional[int] = None + other_requirements: Optional[str] = None + attachments: Optional[List[dict]] = None + + +# ===== 响应 ===== + +class BriefResponse(BaseModel): + """Brief 响应""" + id: str + project_id: str + project_name: Optional[str] = None + file_url: Optional[str] = None + file_name: Optional[str] = None + selling_points: Optional[List[dict]] = None + blacklist_words: Optional[List[dict]] = None + competitors: Optional[List[str]] = None + brand_tone: Optional[str] = None + min_duration: Optional[int] = None + max_duration: Optional[int] = None + other_requirements: Optional[str] = None + attachments: Optional[List[dict]] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..3670479 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -0,0 +1,87 @@ +""" +组织关系相关 Schema +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field + + +# ===== 通用 ===== + +class BrandSummary(BaseModel): + """品牌方摘要""" + id: str + name: str + logo: Optional[str] = None + contact_name: Optional[str] = None + + class Config: + from_attributes = True + + +class AgencySummary(BaseModel): + """代理商摘要""" + id: str + name: str + logo: Optional[str] = None + contact_name: Optional[str] = None + force_pass_enabled: bool = True + + class Config: + from_attributes = True + + +class CreatorSummary(BaseModel): + """达人摘要""" + id: str + name: str + avatar: Optional[str] = None + douyin_account: Optional[str] = None + xiaohongshu_account: Optional[str] = None + bilibili_account: Optional[str] = None + + class Config: + from_attributes = True + + +# ===== 请求 ===== + +class InviteAgencyRequest(BaseModel): + """邀请代理商""" + agency_id: str + + +class InviteCreatorRequest(BaseModel): + """邀请达人""" + creator_id: str + + +class UpdateAgencyPermissionRequest(BaseModel): + """更新代理商权限""" + force_pass_enabled: bool + + +# ===== 响应 ===== + +class OrganizationListResponse(BaseModel): + """组织列表通用响应""" + items: list + total: int + + +class BrandListResponse(BaseModel): + """品牌方列表""" + items: List[BrandSummary] + total: int + + +class AgencyListResponse(BaseModel): + """代理商列表""" + items: List[AgencySummary] + total: int + + +class CreatorListResponse(BaseModel): + """达人列表""" + items: List[CreatorSummary] + total: int diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..b98f67e --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,67 @@ +""" +项目相关 Schema +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field + + +# ===== 请求 ===== + +class ProjectCreateRequest(BaseModel): + """创建项目请求(品牌方操作)""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + start_date: Optional[datetime] = None + deadline: Optional[datetime] = None + agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表 + + +class ProjectUpdateRequest(BaseModel): + """更新项目请求""" + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + start_date: Optional[datetime] = None + deadline: Optional[datetime] = None + status: Optional[str] = Field(None, pattern="^(active|completed|archived)$") + + +class ProjectAssignAgencyRequest(BaseModel): + """分配代理商到项目""" + agency_ids: List[str] + + +# ===== 响应 ===== + +class AgencySummary(BaseModel): + """代理商摘要""" + id: str + name: str + logo: Optional[str] = None + + +class ProjectResponse(BaseModel): + """项目响应""" + id: str + name: str + description: Optional[str] = None + brand_id: str + brand_name: Optional[str] = None + status: str + start_date: Optional[datetime] = None + deadline: Optional[datetime] = None + agencies: List[AgencySummary] = [] + task_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProjectListResponse(BaseModel): + """项目列表响应""" + items: List[ProjectResponse] + total: int + page: int + page_size: int diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..797e39e --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,171 @@ +""" +任务相关 Schema +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field +from app.models.task import TaskStage, TaskStatus + + +# ===== 通用 ===== + +class AIReviewResult(BaseModel): + """AI 审核结果""" + score: int = Field(..., ge=0, le=100) + violations: List[dict] = [] + soft_warnings: List[dict] = [] + summary: Optional[str] = None + + +class ReviewAction(BaseModel): + """审核操作""" + action: str = Field(..., pattern="^(pass|reject|force_pass)$") + comment: Optional[str] = None + + +# ===== 请求 ===== + +class TaskCreateRequest(BaseModel): + """创建任务请求(代理商操作)""" + project_id: str + creator_id: str + name: Optional[str] = None # 不传则自动生成 "宣传任务(N)" + + +class TaskScriptUploadRequest(BaseModel): + """上传脚本请求""" + file_url: str + file_name: str + + +class TaskVideoUploadRequest(BaseModel): + """上传视频请求""" + file_url: str + file_name: str + duration: Optional[int] = None # 秒 + thumbnail_url: Optional[str] = None + + +class TaskReviewRequest(BaseModel): + """审核请求""" + action: str = Field(..., pattern="^(pass|reject|force_pass)$") + comment: Optional[str] = None + + +class AppealRequest(BaseModel): + """申诉请求""" + reason: str = Field(..., min_length=1) + + +class AppealCountRequest(BaseModel): + """申请增加申诉次数请求""" + task_id: str + + +class AppealCountActionRequest(BaseModel): + """处理申诉次数请求""" + action: str = Field(..., pattern="^(approve|reject)$") + + +# ===== 响应 ===== + +class CreatorInfo(BaseModel): + """达人信息""" + id: str + name: str + avatar: Optional[str] = None + + +class AgencyInfo(BaseModel): + """代理商信息""" + id: str + name: str + + +class ProjectInfo(BaseModel): + """项目信息""" + id: str + name: str + brand_name: Optional[str] = None + + +class TaskResponse(BaseModel): + """任务响应""" + id: str + name: str + sequence: int + stage: TaskStage + + # 关联信息 + project: ProjectInfo + agency: AgencyInfo + creator: CreatorInfo + + # 脚本信息 + script_file_url: Optional[str] = None + script_file_name: Optional[str] = None + script_uploaded_at: Optional[datetime] = None + script_ai_score: Optional[int] = None + script_ai_result: Optional[dict] = None + script_agency_status: Optional[TaskStatus] = None + script_agency_comment: Optional[str] = None + script_brand_status: Optional[TaskStatus] = None + script_brand_comment: Optional[str] = None + + # 视频信息 + video_file_url: Optional[str] = None + video_file_name: Optional[str] = None + video_duration: Optional[int] = None + video_thumbnail_url: Optional[str] = None + video_uploaded_at: Optional[datetime] = None + video_ai_score: Optional[int] = None + video_ai_result: Optional[dict] = None + video_agency_status: Optional[TaskStatus] = None + video_agency_comment: Optional[str] = None + video_brand_status: Optional[TaskStatus] = None + video_brand_comment: Optional[str] = None + + # 申诉 + appeal_count: int = 1 + is_appeal: bool = False + appeal_reason: Optional[str] = None + + # 时间 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TaskListResponse(BaseModel): + """任务列表响应""" + items: List[TaskResponse] + total: int + page: int + page_size: int + + +class TaskSummary(BaseModel): + """任务摘要(用于列表)""" + id: str + name: str + stage: TaskStage + creator_name: str + creator_avatar: Optional[str] = None + project_name: str + is_appeal: bool = False + appeal_reason: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ReviewTaskListResponse(BaseModel): + """待审核任务列表响应""" + items: List[TaskSummary] + total: int + page: int + page_size: int diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..afc18df --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,633 @@ +""" +任务服务 +处理任务的创建、状态流转、审核等业务逻辑 +""" +from typing import Optional, List, Tuple +from datetime import datetime, timezone +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.task import Task, TaskStage, TaskStatus +from app.models.project import Project +from app.models.organization import Brand, Agency, Creator +from app.models.user import User, UserRole +from app.services.auth import generate_id + + +async def get_next_task_sequence( + db: AsyncSession, + project_id: str, + creator_id: str, +) -> int: + """获取该项目下该达人的下一个任务序号""" + result = await db.execute( + select(func.count(Task.id)).where( + and_( + Task.project_id == project_id, + Task.creator_id == creator_id, + ) + ) + ) + count = result.scalar() or 0 + return count + 1 + + +async def create_task( + db: AsyncSession, + project_id: str, + agency_id: str, + creator_id: str, + name: Optional[str] = None, +) -> Task: + """ + 创建任务(代理商操作) + + - 自动生成任务名称 "宣传任务(N)" + - 初始阶段: script_upload + """ + # 获取序号 + sequence = await get_next_task_sequence(db, project_id, creator_id) + + # 生成任务名称 + if not name: + name = f"宣传任务({sequence})" + + task = Task( + id=generate_id("TK"), + project_id=project_id, + agency_id=agency_id, + creator_id=creator_id, + name=name, + sequence=sequence, + stage=TaskStage.SCRIPT_UPLOAD, + appeal_count=1, # 初始申诉次数 + ) + + db.add(task) + await db.flush() + await db.refresh(task) + + return task + + +async def get_task_by_id( + db: AsyncSession, + task_id: str, +) -> Optional[Task]: + """通过 ID 获取任务(带关联加载)""" + result = await db.execute( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where(Task.id == task_id) + ) + return result.scalar_one_or_none() + + +async def check_task_permission( + task: Task, + user: User, + db: AsyncSession, +) -> bool: + """ + 检查用户是否有权限访问任务 + + - 达人: 只能访问分配给自己的任务 + - 代理商: 只能访问自己创建的任务 + - 品牌方: 可以访问自己项目下的所有任务 + """ + if user.role == UserRole.CREATOR: + result = await db.execute( + select(Creator).where(Creator.user_id == user.id) + ) + creator = result.scalar_one_or_none() + return creator and task.creator_id == creator.id + + elif user.role == UserRole.AGENCY: + result = await db.execute( + select(Agency).where(Agency.user_id == user.id) + ) + agency = result.scalar_one_or_none() + return agency and task.agency_id == agency.id + + elif user.role == UserRole.BRAND: + result = await db.execute( + select(Brand).where(Brand.user_id == user.id) + ) + brand = result.scalar_one_or_none() + if not brand: + return False + + result = await db.execute( + select(Project).where(Project.id == task.project_id) + ) + project = result.scalar_one_or_none() + return project and project.brand_id == brand.id + + return False + + +async def upload_script( + db: AsyncSession, + task: Task, + file_url: str, + file_name: str, +) -> Task: + """ + 上传脚本(达人操作) + + - 更新脚本信息 + - 状态流转到 script_ai_review + """ + if task.stage not in [TaskStage.SCRIPT_UPLOAD, TaskStage.REJECTED]: + raise ValueError(f"当前阶段 {task.stage.value} 不允许上传脚本") + + task.script_file_url = file_url + task.script_file_name = file_name + task.script_uploaded_at = datetime.now(timezone.utc) + task.stage = TaskStage.SCRIPT_AI_REVIEW + + # 如果是申诉重新上传,重置申诉状态 + if task.is_appeal: + task.is_appeal = False + task.appeal_reason = None + + await db.flush() + await db.refresh(task) + return task + + +async def upload_video( + db: AsyncSession, + task: Task, + file_url: str, + file_name: str, + duration: Optional[int] = None, + thumbnail_url: Optional[str] = None, +) -> Task: + """ + 上传视频(达人操作) + + - 更新视频信息 + - 状态流转到 video_ai_review + """ + if task.stage not in [TaskStage.VIDEO_UPLOAD, TaskStage.REJECTED]: + raise ValueError(f"当前阶段 {task.stage.value} 不允许上传视频") + + task.video_file_url = file_url + task.video_file_name = file_name + task.video_duration = duration + task.video_thumbnail_url = thumbnail_url + task.video_uploaded_at = datetime.now(timezone.utc) + task.stage = TaskStage.VIDEO_AI_REVIEW + + # 如果是申诉重新上传,重置申诉状态 + if task.is_appeal: + task.is_appeal = False + task.appeal_reason = None + + await db.flush() + await db.refresh(task) + return task + + +async def complete_ai_review( + db: AsyncSession, + task: Task, + review_type: str, # "script" or "video" + score: int, + result: dict, +) -> Task: + """ + 完成 AI 审核 + + - 更新 AI 审核结果 + - 状态流转到代理商审核 + """ + now = datetime.now(timezone.utc) + + if review_type == "script": + if task.stage != TaskStage.SCRIPT_AI_REVIEW: + raise ValueError(f"当前阶段 {task.stage.value} 不在脚本 AI 审核中") + + task.script_ai_score = score + task.script_ai_result = result + task.script_ai_reviewed_at = now + task.stage = TaskStage.SCRIPT_AGENCY_REVIEW + + elif review_type == "video": + if task.stage != TaskStage.VIDEO_AI_REVIEW: + raise ValueError(f"当前阶段 {task.stage.value} 不在视频 AI 审核中") + + task.video_ai_score = score + task.video_ai_result = result + task.video_ai_reviewed_at = now + task.stage = TaskStage.VIDEO_AGENCY_REVIEW + + else: + raise ValueError(f"不支持的审核类型: {review_type}") + + await db.flush() + await db.refresh(task) + return task + + +async def agency_review( + db: AsyncSession, + task: Task, + reviewer_id: str, + action: str, # "pass" | "reject" | "force_pass" + comment: Optional[str] = None, +) -> Task: + """ + 代理商审核 + + - pass: 通过,进入品牌方审核(如果开启)或下一阶段 + - reject: 驳回,回到上传阶段 + - force_pass: 强制通过,跳过品牌方审核 + """ + now = datetime.now(timezone.utc) + + # 获取项目信息以检查是否开启品牌方终审 + project = await db.execute( + select(Project) + .options(selectinload(Project.brand)) + .where(Project.id == task.project_id) + ) + project = project.scalar_one_or_none() + brand_review_enabled = project and project.brand and project.brand.final_review_enabled + + if task.stage == TaskStage.SCRIPT_AGENCY_REVIEW: + if action == "pass": + task.script_agency_status = TaskStatus.PASSED + if brand_review_enabled: + task.stage = TaskStage.SCRIPT_BRAND_REVIEW + else: + task.stage = TaskStage.VIDEO_UPLOAD + elif action == "reject": + task.script_agency_status = TaskStatus.REJECTED + task.stage = TaskStage.REJECTED + elif action == "force_pass": + task.script_agency_status = TaskStatus.FORCE_PASSED + task.stage = TaskStage.VIDEO_UPLOAD # 跳过品牌方审核 + else: + raise ValueError(f"不支持的操作: {action}") + + task.script_agency_comment = comment + task.script_agency_reviewer_id = reviewer_id + task.script_agency_reviewed_at = now + + elif task.stage == TaskStage.VIDEO_AGENCY_REVIEW: + if action == "pass": + task.video_agency_status = TaskStatus.PASSED + if brand_review_enabled: + task.stage = TaskStage.VIDEO_BRAND_REVIEW + else: + task.stage = TaskStage.COMPLETED + elif action == "reject": + task.video_agency_status = TaskStatus.REJECTED + task.stage = TaskStage.REJECTED + elif action == "force_pass": + task.video_agency_status = TaskStatus.FORCE_PASSED + task.stage = TaskStage.COMPLETED # 跳过品牌方审核 + else: + raise ValueError(f"不支持的操作: {action}") + + task.video_agency_comment = comment + task.video_agency_reviewer_id = reviewer_id + task.video_agency_reviewed_at = now + + else: + raise ValueError(f"当前阶段 {task.stage.value} 不在代理商审核中") + + await db.flush() + await db.refresh(task) + return task + + +async def brand_review( + db: AsyncSession, + task: Task, + reviewer_id: str, + action: str, # "pass" | "reject" + comment: Optional[str] = None, +) -> Task: + """ + 品牌方终审 + + - pass: 通过,进入下一阶段 + - reject: 驳回,回到上传阶段(需要走申诉流程) + """ + now = datetime.now(timezone.utc) + + if task.stage == TaskStage.SCRIPT_BRAND_REVIEW: + if action == "pass": + task.script_brand_status = TaskStatus.PASSED + task.stage = TaskStage.VIDEO_UPLOAD + elif action == "reject": + task.script_brand_status = TaskStatus.REJECTED + task.stage = TaskStage.REJECTED + else: + raise ValueError(f"不支持的操作: {action}") + + task.script_brand_comment = comment + task.script_brand_reviewer_id = reviewer_id + task.script_brand_reviewed_at = now + + elif task.stage == TaskStage.VIDEO_BRAND_REVIEW: + if action == "pass": + task.video_brand_status = TaskStatus.PASSED + task.stage = TaskStage.COMPLETED + elif action == "reject": + task.video_brand_status = TaskStatus.REJECTED + task.stage = TaskStage.REJECTED + else: + raise ValueError(f"不支持的操作: {action}") + + task.video_brand_comment = comment + task.video_brand_reviewer_id = reviewer_id + task.video_brand_reviewed_at = now + + else: + raise ValueError(f"当前阶段 {task.stage.value} 不在品牌方审核中") + + await db.flush() + await db.refresh(task) + return task + + +async def submit_appeal( + db: AsyncSession, + task: Task, + reason: str, +) -> Task: + """ + 提交申诉(达人操作) + + - 使用一次申诉次数 + - 回到对应的上传阶段 + """ + if task.stage != TaskStage.REJECTED: + raise ValueError(f"当前阶段 {task.stage.value} 不允许申诉") + + if task.appeal_count <= 0: + raise ValueError("申诉次数已用完,请联系代理商申请增加") + + # 消耗一次申诉次数 + task.appeal_count -= 1 + task.is_appeal = True + task.appeal_reason = reason + + # 根据驳回阶段回到对应的上传阶段 + # 检查是脚本阶段被驳回还是视频阶段被驳回 + if task.video_agency_status == TaskStatus.REJECTED or task.video_brand_status == TaskStatus.REJECTED: + task.stage = TaskStage.VIDEO_UPLOAD + # 重置视频审核状态 + task.video_agency_status = None + task.video_brand_status = None + else: + task.stage = TaskStage.SCRIPT_UPLOAD + # 重置脚本审核状态 + task.script_agency_status = None + task.script_brand_status = None + + await db.flush() + await db.refresh(task) + return task + + +async def increase_appeal_count( + db: AsyncSession, + task: Task, + additional_count: int = 1, +) -> Task: + """ + 增加申诉次数(代理商操作) + """ + task.appeal_count += additional_count + + await db.flush() + await db.refresh(task) + return task + + +async def list_tasks_for_creator( + db: AsyncSession, + creator_id: str, + page: int = 1, + page_size: int = 20, + stage: Optional[TaskStage] = None, +) -> Tuple[List[Task], int]: + """获取达人的任务列表""" + query = ( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where(Task.creator_id == creator_id) + ) + + if stage: + query = query.where(Task.stage == stage) + + query = query.order_by(Task.created_at.desc()) + + # 获取总数 + count_query = select(func.count(Task.id)).where(Task.creator_id == creator_id) + if stage: + count_query = count_query.where(Task.stage == stage) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + tasks = list(result.scalars().all()) + + return tasks, total + + +async def list_tasks_for_agency( + db: AsyncSession, + agency_id: str, + page: int = 1, + page_size: int = 20, + stage: Optional[TaskStage] = None, +) -> Tuple[List[Task], int]: + """获取代理商的任务列表""" + query = ( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where(Task.agency_id == agency_id) + ) + + if stage: + query = query.where(Task.stage == stage) + + query = query.order_by(Task.created_at.desc()) + + # 获取总数 + count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id) + if stage: + count_query = count_query.where(Task.stage == stage) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + tasks = list(result.scalars().all()) + + return tasks, total + + +async def list_tasks_for_brand( + db: AsyncSession, + brand_id: str, + page: int = 1, + page_size: int = 20, + stage: Optional[TaskStage] = None, +) -> Tuple[List[Task], int]: + """获取品牌方的任务列表(通过项目关联)""" + # 先获取品牌方的所有项目 + project_ids_query = select(Project.id).where(Project.brand_id == brand_id) + project_ids_result = await db.execute(project_ids_query) + project_ids = [row[0] for row in project_ids_result.all()] + + if not project_ids: + return [], 0 + + query = ( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where(Task.project_id.in_(project_ids)) + ) + + if stage: + query = query.where(Task.stage == stage) + + query = query.order_by(Task.created_at.desc()) + + # 获取总数 + count_query = select(func.count(Task.id)).where(Task.project_id.in_(project_ids)) + if stage: + count_query = count_query.where(Task.stage == stage) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + tasks = list(result.scalars().all()) + + return tasks, total + + +async def list_pending_reviews_for_agency( + db: AsyncSession, + agency_id: str, + page: int = 1, + page_size: int = 20, +) -> Tuple[List[Task], int]: + """获取代理商待审核的任务列表""" + stages = [TaskStage.SCRIPT_AGENCY_REVIEW, TaskStage.VIDEO_AGENCY_REVIEW] + + query = ( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where( + and_( + Task.agency_id == agency_id, + Task.stage.in_(stages), + ) + ) + ) + + query = query.order_by(Task.created_at.desc()) + + # 获取总数 + count_query = select(func.count(Task.id)).where( + and_( + Task.agency_id == agency_id, + Task.stage.in_(stages), + ) + ) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + tasks = list(result.scalars().all()) + + return tasks, total + + +async def list_pending_reviews_for_brand( + db: AsyncSession, + brand_id: str, + page: int = 1, + page_size: int = 20, +) -> Tuple[List[Task], int]: + """获取品牌方待审核的任务列表""" + # 先获取品牌方的所有项目 + project_ids_query = select(Project.id).where(Project.brand_id == brand_id) + project_ids_result = await db.execute(project_ids_query) + project_ids = [row[0] for row in project_ids_result.all()] + + if not project_ids: + return [], 0 + + stages = [TaskStage.SCRIPT_BRAND_REVIEW, TaskStage.VIDEO_BRAND_REVIEW] + + query = ( + select(Task) + .options( + selectinload(Task.project), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .where( + and_( + Task.project_id.in_(project_ids), + Task.stage.in_(stages), + ) + ) + ) + + query = query.order_by(Task.created_at.desc()) + + # 获取总数 + count_query = select(func.count(Task.id)).where( + and_( + Task.project_id.in_(project_ids), + Task.stage.in_(stages), + ) + ) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # 分页 + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + tasks = list(result.scalars().all()) + + return tasks, total diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index db39ca3..abe0f23 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -67,7 +67,7 @@ function LoginForm() { const result = await login({ email, password }) if (result.success) { - const stored = localStorage.getItem('miaosi_auth') + const stored = localStorage.getItem('miaosi_user') if (stored) { const user = JSON.parse(stored) switch (user.role) { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 73dde8d..c492523 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -14,7 +14,32 @@ import type { TaskListResponse, TaskScriptUploadRequest, TaskVideoUploadRequest, + TaskCreateRequest, + TaskReviewRequest, + TaskStage, + ReviewTaskListResponse, + AppealRequest, } from '@/types/task' +import type { + ProjectResponse, + ProjectListResponse, + ProjectCreateRequest, + ProjectUpdateRequest, +} from '@/types/project' +import type { + BriefResponse, + BriefCreateRequest, +} from '@/types/brief' +import type { + AgencyListResponse, + CreatorListResponse, + BrandListResponse, +} from '@/types/organization' +import type { + CreatorDashboard, + AgencyDashboard, + BrandDashboard, +} from '@/types/dashboard' const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' const STORAGE_KEY_ACCESS = 'miaosi_access_token' @@ -305,11 +330,29 @@ class ApiClient { // ==================== 审核任务 ==================== + /** + * 创建任务(代理商操作) + */ + async createTask(data: TaskCreateRequest): Promise { + const response = await this.client.post('/tasks', data) + return response.data + } + /** * 查询任务列表 */ - async listTasks(page: number = 1, pageSize: number = 20): Promise { + async listTasks(page: number = 1, pageSize: number = 20, stage?: TaskStage): Promise { const response = await this.client.get('/tasks', { + params: { page, page_size: pageSize, stage }, + }) + return response.data + } + + /** + * 查询待审核任务列表 + */ + async listPendingReviews(page: number = 1, pageSize: number = 20): Promise { + const response = await this.client.get('/tasks/pending', { params: { page, page_size: pageSize }, }) return response.data @@ -339,6 +382,227 @@ class ApiClient { return response.data } + /** + * 审核脚本 + */ + async reviewScript(taskId: string, data: TaskReviewRequest): Promise { + const response = await this.client.post(`/tasks/${taskId}/script/review`, data) + return response.data + } + + /** + * 审核视频 + */ + async reviewVideo(taskId: string, data: TaskReviewRequest): Promise { + const response = await this.client.post(`/tasks/${taskId}/video/review`, data) + return response.data + } + + /** + * 提交申诉(达人操作) + */ + async submitAppeal(taskId: string, data: AppealRequest): Promise { + const response = await this.client.post(`/tasks/${taskId}/appeal`, data) + return response.data + } + + /** + * 增加申诉次数(代理商操作) + */ + async increaseAppealCount(taskId: string): Promise { + const response = await this.client.post(`/tasks/${taskId}/appeal-count`) + return response.data + } + + // ==================== 项目 ==================== + + /** + * 创建项目(品牌方操作) + */ + async createProject(data: ProjectCreateRequest): Promise { + const response = await this.client.post('/projects', data) + return response.data + } + + /** + * 查询项目列表 + */ + async listProjects(page: number = 1, pageSize: number = 20, status?: string): Promise { + const response = await this.client.get('/projects', { + params: { page, page_size: pageSize, status }, + }) + return response.data + } + + /** + * 查询项目详情 + */ + async getProject(projectId: string): Promise { + const response = await this.client.get(`/projects/${projectId}`) + return response.data + } + + /** + * 更新项目 + */ + async updateProject(projectId: string, data: ProjectUpdateRequest): Promise { + const response = await this.client.put(`/projects/${projectId}`, data) + return response.data + } + + /** + * 分配代理商到项目 + */ + async assignAgencies(projectId: string, agencyIds: string[]): Promise { + const response = await this.client.post(`/projects/${projectId}/agencies`, { + agency_ids: agencyIds, + }) + return response.data + } + + /** + * 从项目移除代理商 + */ + async removeAgencyFromProject(projectId: string, agencyId: string): Promise { + const response = await this.client.delete(`/projects/${projectId}/agencies/${agencyId}`) + return response.data + } + + // ==================== Brief ==================== + + /** + * 获取项目 Brief + */ + async getBrief(projectId: string): Promise { + const response = await this.client.get(`/projects/${projectId}/brief`) + return response.data + } + + /** + * 创建项目 Brief + */ + async createBrief(projectId: string, data: BriefCreateRequest): Promise { + const response = await this.client.post(`/projects/${projectId}/brief`, data) + return response.data + } + + /** + * 更新项目 Brief + */ + async updateBrief(projectId: string, data: BriefCreateRequest): Promise { + const response = await this.client.put(`/projects/${projectId}/brief`, data) + return response.data + } + + // ==================== 组织关系 ==================== + + /** + * 品牌方:查询代理商列表 + */ + async listBrandAgencies(): Promise { + const response = await this.client.get('/organizations/brand/agencies') + return response.data + } + + /** + * 品牌方:邀请代理商 + */ + async inviteAgency(agencyId: string): Promise { + await this.client.post('/organizations/brand/agencies', { agency_id: agencyId }) + } + + /** + * 品牌方:移除代理商 + */ + async removeAgency(agencyId: string): Promise { + await this.client.delete(`/organizations/brand/agencies/${agencyId}`) + } + + /** + * 品牌方:更新代理商权限 + */ + async updateAgencyPermission(agencyId: string, forcePassEnabled: boolean): Promise { + await this.client.put(`/organizations/brand/agencies/${agencyId}/permission`, { + force_pass_enabled: forcePassEnabled, + }) + } + + /** + * 代理商:查询达人列表 + */ + async listAgencyCreators(): Promise { + const response = await this.client.get('/organizations/agency/creators') + return response.data + } + + /** + * 代理商:邀请达人 + */ + async inviteCreator(creatorId: string): Promise { + await this.client.post('/organizations/agency/creators', { creator_id: creatorId }) + } + + /** + * 代理商:移除达人 + */ + async removeCreator(creatorId: string): Promise { + await this.client.delete(`/organizations/agency/creators/${creatorId}`) + } + + /** + * 代理商:查询关联品牌方 + */ + async listAgencyBrands(): Promise { + const response = await this.client.get('/organizations/agency/brands') + return response.data + } + + /** + * 搜索代理商 + */ + async searchAgencies(keyword: string): Promise { + const response = await this.client.get('/organizations/search/agencies', { + params: { keyword }, + }) + return response.data + } + + /** + * 搜索达人 + */ + async searchCreators(keyword: string): Promise { + const response = await this.client.get('/organizations/search/creators', { + params: { keyword }, + }) + return response.data + } + + // ==================== 工作台统计 ==================== + + /** + * 达人工作台数据 + */ + async getCreatorDashboard(): Promise { + const response = await this.client.get('/dashboard/creator') + return response.data + } + + /** + * 代理商工作台数据 + */ + async getAgencyDashboard(): Promise { + const response = await this.client.get('/dashboard/agency') + return response.data + } + + /** + * 品牌方工作台数据 + */ + async getBrandDashboard(): Promise { + const response = await this.client.get('/dashboard/brand') + return response.data + } + // ==================== 健康检查 ==================== /** diff --git a/frontend/types/brief.ts b/frontend/types/brief.ts new file mode 100644 index 0000000..c777544 --- /dev/null +++ b/frontend/types/brief.ts @@ -0,0 +1,52 @@ +/** + * Brief 相关类型定义 + * 与后端 BriefResponse 对齐 + */ + +export interface BriefAttachment { + id: string + name: string + url: string + size?: string +} + +export interface SellingPoint { + content: string + required: boolean +} + +export interface BlacklistWord { + word: string + reason: string +} + +export interface BriefResponse { + id: string + project_id: string + project_name?: string | null + file_url?: string | null + file_name?: string | null + selling_points?: SellingPoint[] | null + blacklist_words?: BlacklistWord[] | null + competitors?: string[] | null + brand_tone?: string | null + min_duration?: number | null + max_duration?: number | null + other_requirements?: string | null + attachments?: BriefAttachment[] | null + created_at: string + updated_at: string +} + +export interface BriefCreateRequest { + file_url?: string + file_name?: string + selling_points?: SellingPoint[] + blacklist_words?: BlacklistWord[] + competitors?: string[] + brand_tone?: string + min_duration?: number + max_duration?: number + other_requirements?: string + attachments?: BriefAttachment[] +} diff --git a/frontend/types/dashboard.ts b/frontend/types/dashboard.ts new file mode 100644 index 0000000..19ca8ad --- /dev/null +++ b/frontend/types/dashboard.ts @@ -0,0 +1,36 @@ +/** + * 工作台统计类型定义 + * 与后端 Dashboard schemas 对齐 + */ + +export interface ReviewCount { + script: number + video: number +} + +export interface CreatorDashboard { + total_tasks: number + pending_script: number + pending_video: number + in_review: number + completed: number + rejected: number +} + +export interface AgencyDashboard { + pending_review: ReviewCount + pending_appeal: number + today_passed: ReviewCount + in_progress: ReviewCount + total_creators: number + total_tasks: number +} + +export interface BrandDashboard { + total_projects: number + active_projects: number + pending_review: ReviewCount + total_agencies: number + total_tasks: number + completed_tasks: number +} diff --git a/frontend/types/organization.ts b/frontend/types/organization.ts new file mode 100644 index 0000000..7081528 --- /dev/null +++ b/frontend/types/organization.ts @@ -0,0 +1,43 @@ +/** + * 组织关系类型定义 + * 与后端 Organization schemas 对齐 + */ + +export interface BrandSummary { + id: string + name: string + logo?: string | null + contact_name?: string | null +} + +export interface AgencyDetail { + id: string + name: string + logo?: string | null + contact_name?: string | null + force_pass_enabled: boolean +} + +export interface CreatorDetail { + id: string + name: string + avatar?: string | null + douyin_account?: string | null + xiaohongshu_account?: string | null + bilibili_account?: string | null +} + +export interface BrandListResponse { + items: BrandSummary[] + total: number +} + +export interface AgencyListResponse { + items: AgencyDetail[] + total: number +} + +export interface CreatorListResponse { + items: CreatorDetail[] + total: number +} diff --git a/frontend/types/project.ts b/frontend/types/project.ts new file mode 100644 index 0000000..6e1519b --- /dev/null +++ b/frontend/types/project.ts @@ -0,0 +1,48 @@ +/** + * 项目相关类型定义 + * 与后端 ProjectResponse 对齐 + */ + +export interface AgencySummary { + id: string + name: string + logo?: string | null +} + +export interface ProjectResponse { + id: string + name: string + description?: string | null + brand_id: string + brand_name?: string | null + status: string + start_date?: string | null + deadline?: string | null + agencies: AgencySummary[] + task_count: number + created_at: string + updated_at: string +} + +export interface ProjectListResponse { + items: ProjectResponse[] + total: number + page: number + page_size: number +} + +export interface ProjectCreateRequest { + name: string + description?: string + start_date?: string + deadline?: string + agency_ids?: string[] +} + +export interface ProjectUpdateRequest { + name?: string + description?: string + start_date?: string + deadline?: string + status?: 'active' | 'completed' | 'archived' +} diff --git a/frontend/types/task.ts b/frontend/types/task.ts index 03c2849..2b7c45c 100644 --- a/frontend/types/task.ts +++ b/frontend/types/task.ts @@ -1,22 +1,110 @@ -export type ApiTaskStatus = - | 'pending' - | 'processing' +/** + * 任务相关类型定义 + * 与后端 TaskStage/TaskStatus/TaskResponse 对齐 + */ + +// 任务阶段(对应后端 TaskStage) +export type TaskStage = + | 'script_upload' + | 'script_ai_review' + | 'script_agency_review' + | 'script_brand_review' + | 'video_upload' + | 'video_ai_review' + | 'video_agency_review' + | 'video_brand_review' | 'completed' - | 'failed' - | 'approved' | 'rejected' +// 审核状态(对应后端 TaskStatus) +export type TaskStatus = + | 'pending' + | 'processing' + | 'passed' + | 'rejected' + | 'force_passed' + +// 关联信息 +export interface ProjectInfo { + id: string + name: string + brand_name?: string | null +} + +export interface AgencyInfo { + id: string + name: string +} + +export interface CreatorInfo { + id: string + name: string + avatar?: string | null +} + +// AI 审核结果 +export interface AIReviewResult { + score: number + violations: Array<{ + type: string + content: string + severity: string + suggestion: string + timestamp?: number + source?: string + }> + soft_warnings: Array<{ + type: string + content: string + suggestion: string + }> + summary?: string +} + +// 任务响应(对应后端 TaskResponse) export interface TaskResponse { - task_id: string - video_url?: string | null - script_content?: string | null + id: string + name: string + sequence: number + stage: TaskStage + + // 关联 + project: ProjectInfo + agency: AgencyInfo + creator: CreatorInfo + + // 脚本信息 script_file_url?: string | null - has_script: boolean - has_video: boolean - platform: string - creator_id: string - status: ApiTaskStatus + script_file_name?: string | null + script_uploaded_at?: string | null + script_ai_score?: number | null + script_ai_result?: AIReviewResult | null + script_agency_status?: TaskStatus | null + script_agency_comment?: string | null + script_brand_status?: TaskStatus | null + script_brand_comment?: string | null + + // 视频信息 + video_file_url?: string | null + video_file_name?: string | null + video_duration?: number | null + video_thumbnail_url?: string | null + video_uploaded_at?: string | null + video_ai_score?: number | null + video_ai_result?: AIReviewResult | null + video_agency_status?: TaskStatus | null + video_agency_comment?: string | null + video_brand_status?: TaskStatus | null + video_brand_comment?: string | null + + // 申诉 + appeal_count: number + is_appeal: boolean + appeal_reason?: string | null + + // 时间 created_at: string + updated_at: string } export interface TaskListResponse { @@ -26,11 +114,50 @@ export interface TaskListResponse { page_size: number } +export interface TaskSummary { + id: string + name: string + stage: TaskStage + creator_name: string + creator_avatar?: string | null + project_name: string + is_appeal: boolean + appeal_reason?: string | null + created_at: string + updated_at: string +} + +export interface ReviewTaskListResponse { + items: TaskSummary[] + total: number + page: number + page_size: number +} + +// 请求类型 +export interface TaskCreateRequest { + project_id: string + creator_id: string + name?: string +} + export interface TaskScriptUploadRequest { - script_content?: string - script_file_url?: string + file_url: string + file_name: string } export interface TaskVideoUploadRequest { - video_url: string + file_url: string + file_name: string + duration?: number + thumbnail_url?: string +} + +export interface TaskReviewRequest { + action: 'pass' | 'reject' | 'force_pass' + comment?: string +} + +export interface AppealRequest { + reason: string }