feat: 补全后端 API 并对齐前后端类型
- 后端新增: Project CRUD / Brief CRUD / 组织关系管理 / 工作台统计 / SSE 推送 / 认证依赖注入 - 后端完善: 任务 API 全流程(创建/审核/申诉) + Task Service + Task Schema - 前端修复: login 页面 localStorage key 错误 (miaosi_auth -> miaosi_user) - 前端对齐: types/task.ts 与后端 TaskStage/TaskResponse 完全对齐 - 前端新增: project/brief/organization/dashboard 类型定义 - 前端补全: api.ts 新增 30+ API 方法覆盖所有后端接口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
23835ee790
commit
a32102f583
182
backend/app/api/briefs.py
Normal file
182
backend/app/api/briefs.py
Normal file
@ -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)
|
||||
289
backend/app/api/dashboard.py
Normal file
289
backend/app/api/dashboard.py
Normal file
@ -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,
|
||||
)
|
||||
172
backend/app/api/deps.py
Normal file
172
backend/app/api/deps.py
Normal file
@ -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
|
||||
322
backend/app/api/organizations.py
Normal file
322
backend/app/api/organizations.py
Normal file
@ -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
|
||||
]
|
||||
}
|
||||
328
backend/app/api/projects.py
Normal file
328
backend/app/api/projects.py
Normal file
@ -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)
|
||||
235
backend/app/api/sse.py
Normal file
235
backend/app/api/sse.py
Normal file
@ -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,
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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("/")
|
||||
|
||||
60
backend/app/schemas/brief.py
Normal file
60
backend/app/schemas/brief.py
Normal file
@ -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
|
||||
87
backend/app/schemas/organization.py
Normal file
87
backend/app/schemas/organization.py
Normal file
@ -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
|
||||
67
backend/app/schemas/project.py
Normal file
67
backend/app/schemas/project.py
Normal file
@ -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
|
||||
171
backend/app/schemas/task.py
Normal file
171
backend/app/schemas/task.py
Normal file
@ -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
|
||||
633
backend/app/services/task_service.py
Normal file
633
backend/app/services/task_service.py
Normal file
@ -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
|
||||
@ -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) {
|
||||
|
||||
@ -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<TaskResponse> {
|
||||
const response = await this.client.post<TaskResponse>('/tasks', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务列表
|
||||
*/
|
||||
async listTasks(page: number = 1, pageSize: number = 20): Promise<TaskListResponse> {
|
||||
async listTasks(page: number = 1, pageSize: number = 20, stage?: TaskStage): Promise<TaskListResponse> {
|
||||
const response = await this.client.get<TaskListResponse>('/tasks', {
|
||||
params: { page, page_size: pageSize, stage },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询待审核任务列表
|
||||
*/
|
||||
async listPendingReviews(page: number = 1, pageSize: number = 20): Promise<ReviewTaskListResponse> {
|
||||
const response = await this.client.get<ReviewTaskListResponse>('/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<TaskResponse> {
|
||||
const response = await this.client.post<TaskResponse>(`/tasks/${taskId}/script/review`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核视频
|
||||
*/
|
||||
async reviewVideo(taskId: string, data: TaskReviewRequest): Promise<TaskResponse> {
|
||||
const response = await this.client.post<TaskResponse>(`/tasks/${taskId}/video/review`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交申诉(达人操作)
|
||||
*/
|
||||
async submitAppeal(taskId: string, data: AppealRequest): Promise<TaskResponse> {
|
||||
const response = await this.client.post<TaskResponse>(`/tasks/${taskId}/appeal`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加申诉次数(代理商操作)
|
||||
*/
|
||||
async increaseAppealCount(taskId: string): Promise<TaskResponse> {
|
||||
const response = await this.client.post<TaskResponse>(`/tasks/${taskId}/appeal-count`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== 项目 ====================
|
||||
|
||||
/**
|
||||
* 创建项目(品牌方操作)
|
||||
*/
|
||||
async createProject(data: ProjectCreateRequest): Promise<ProjectResponse> {
|
||||
const response = await this.client.post<ProjectResponse>('/projects', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目列表
|
||||
*/
|
||||
async listProjects(page: number = 1, pageSize: number = 20, status?: string): Promise<ProjectListResponse> {
|
||||
const response = await this.client.get<ProjectListResponse>('/projects', {
|
||||
params: { page, page_size: pageSize, status },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目详情
|
||||
*/
|
||||
async getProject(projectId: string): Promise<ProjectResponse> {
|
||||
const response = await this.client.get<ProjectResponse>(`/projects/${projectId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
async updateProject(projectId: string, data: ProjectUpdateRequest): Promise<ProjectResponse> {
|
||||
const response = await this.client.put<ProjectResponse>(`/projects/${projectId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配代理商到项目
|
||||
*/
|
||||
async assignAgencies(projectId: string, agencyIds: string[]): Promise<ProjectResponse> {
|
||||
const response = await this.client.post<ProjectResponse>(`/projects/${projectId}/agencies`, {
|
||||
agency_ids: agencyIds,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目移除代理商
|
||||
*/
|
||||
async removeAgencyFromProject(projectId: string, agencyId: string): Promise<ProjectResponse> {
|
||||
const response = await this.client.delete<ProjectResponse>(`/projects/${projectId}/agencies/${agencyId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== Brief ====================
|
||||
|
||||
/**
|
||||
* 获取项目 Brief
|
||||
*/
|
||||
async getBrief(projectId: string): Promise<BriefResponse> {
|
||||
const response = await this.client.get<BriefResponse>(`/projects/${projectId}/brief`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目 Brief
|
||||
*/
|
||||
async createBrief(projectId: string, data: BriefCreateRequest): Promise<BriefResponse> {
|
||||
const response = await this.client.post<BriefResponse>(`/projects/${projectId}/brief`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目 Brief
|
||||
*/
|
||||
async updateBrief(projectId: string, data: BriefCreateRequest): Promise<BriefResponse> {
|
||||
const response = await this.client.put<BriefResponse>(`/projects/${projectId}/brief`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== 组织关系 ====================
|
||||
|
||||
/**
|
||||
* 品牌方:查询代理商列表
|
||||
*/
|
||||
async listBrandAgencies(): Promise<AgencyListResponse> {
|
||||
const response = await this.client.get<AgencyListResponse>('/organizations/brand/agencies')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌方:邀请代理商
|
||||
*/
|
||||
async inviteAgency(agencyId: string): Promise<void> {
|
||||
await this.client.post('/organizations/brand/agencies', { agency_id: agencyId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌方:移除代理商
|
||||
*/
|
||||
async removeAgency(agencyId: string): Promise<void> {
|
||||
await this.client.delete(`/organizations/brand/agencies/${agencyId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌方:更新代理商权限
|
||||
*/
|
||||
async updateAgencyPermission(agencyId: string, forcePassEnabled: boolean): Promise<void> {
|
||||
await this.client.put(`/organizations/brand/agencies/${agencyId}/permission`, {
|
||||
force_pass_enabled: forcePassEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商:查询达人列表
|
||||
*/
|
||||
async listAgencyCreators(): Promise<CreatorListResponse> {
|
||||
const response = await this.client.get<CreatorListResponse>('/organizations/agency/creators')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商:邀请达人
|
||||
*/
|
||||
async inviteCreator(creatorId: string): Promise<void> {
|
||||
await this.client.post('/organizations/agency/creators', { creator_id: creatorId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商:移除达人
|
||||
*/
|
||||
async removeCreator(creatorId: string): Promise<void> {
|
||||
await this.client.delete(`/organizations/agency/creators/${creatorId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商:查询关联品牌方
|
||||
*/
|
||||
async listAgencyBrands(): Promise<BrandListResponse> {
|
||||
const response = await this.client.get<BrandListResponse>('/organizations/agency/brands')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索代理商
|
||||
*/
|
||||
async searchAgencies(keyword: string): Promise<AgencyListResponse> {
|
||||
const response = await this.client.get<AgencyListResponse>('/organizations/search/agencies', {
|
||||
params: { keyword },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索达人
|
||||
*/
|
||||
async searchCreators(keyword: string): Promise<CreatorListResponse> {
|
||||
const response = await this.client.get<CreatorListResponse>('/organizations/search/creators', {
|
||||
params: { keyword },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== 工作台统计 ====================
|
||||
|
||||
/**
|
||||
* 达人工作台数据
|
||||
*/
|
||||
async getCreatorDashboard(): Promise<CreatorDashboard> {
|
||||
const response = await this.client.get<CreatorDashboard>('/dashboard/creator')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商工作台数据
|
||||
*/
|
||||
async getAgencyDashboard(): Promise<AgencyDashboard> {
|
||||
const response = await this.client.get<AgencyDashboard>('/dashboard/agency')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌方工作台数据
|
||||
*/
|
||||
async getBrandDashboard(): Promise<BrandDashboard> {
|
||||
const response = await this.client.get<BrandDashboard>('/dashboard/brand')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== 健康检查 ====================
|
||||
|
||||
/**
|
||||
|
||||
52
frontend/types/brief.ts
Normal file
52
frontend/types/brief.ts
Normal file
@ -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[]
|
||||
}
|
||||
36
frontend/types/dashboard.ts
Normal file
36
frontend/types/dashboard.ts
Normal file
@ -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
|
||||
}
|
||||
43
frontend/types/organization.ts
Normal file
43
frontend/types/organization.ts
Normal file
@ -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
|
||||
}
|
||||
48
frontend/types/project.ts
Normal file
48
frontend/types/project.ts
Normal file
@ -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'
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user