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
co-authored by Claude Opus 4.6
parent 23835ee790
commit a32102f583
20 changed files with 3775 additions and 260 deletions
+182
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)
+289
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
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
+322
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
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
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,
})
+635 -241
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)