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:
Your Name 2026-02-09 14:13:08 +08:00
parent 23835ee790
commit a32102f583
20 changed files with 3775 additions and 260 deletions

182
backend/app/api/briefs.py Normal file
View 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)

View 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
View 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

View 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
View 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
View 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,
})

View File

@ -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)

View File

@ -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("/")

View 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

View 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

View 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
View 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

View 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

View File

@ -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) {

View File

@ -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
View 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[]
}

View 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
}

View 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
View 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'
}

View File

@ -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
}