Your Name 0b3dfa3c52 feat: AI 审核自动驳回 + 功效词可配置 + UI 修复
- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段
- 功效词可配置:从硬编码改为品牌方在规则页面自行管理
- 驳回通知:AI 驳回时只通知达人,含具体原因
- 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口
- 规则页面:新增"功效词"分类
- 种子数据:新增 6 条默认功效词
- 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:24:32 +08:00

695 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
任务服务
处理任务的创建、状态流转、审核等业务逻辑
"""
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(Project.brand),
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
AI_AUTO_REJECT_SCORE = 40
def _check_ai_auto_reject(score: int, result: dict) -> tuple[bool, str]:
"""
判断 AI 审核结果是否应自动驳回
触发条件(任一):
1. 法规合规维度存在 HIGH 级违规(违禁词/功效词)
2. 品牌安全维度存在 HIGH 级违规(竞品提及)
3. 总分 < 40
"""
reasons = []
violations = result.get("violations", [])
# 条件1: 法规 HIGH
high_legal = [v for v in violations if v.get("dimension") == "legal" and v.get("severity") == "high"]
if high_legal:
words = [v.get("content", "") for v in high_legal[:5]]
reasons.append(f"法规违规:{', '.join(words)}")
# 条件2: 品牌安全 HIGH
high_brand = [v for v in violations if v.get("dimension") == "brand_safety" and v.get("severity") == "high"]
if high_brand:
words = [v.get("content", "") for v in high_brand[:5]]
reasons.append(f"品牌安全违规:{', '.join(words)}")
# 条件3: 总分过低
if score < AI_AUTO_REJECT_SCORE:
reasons.append(f"综合评分 {score} 分,低于合格线 {AI_AUTO_REJECT_SCORE}")
if reasons:
return True, "".join(reasons)
return False, ""
async def complete_ai_review(
db: AsyncSession,
task: Task,
review_type: str, # "script" or "video"
score: int,
result: dict,
) -> Task:
"""
完成 AI 审核
- 更新 AI 审核结果
- 自动驳回:法规/品牌安全 HIGH 违规或总分 < 40 → 回到上传阶段
- 正常:流转到代理商审核
"""
now = datetime.now(timezone.utc)
auto_rejected, reject_reason = _check_ai_auto_reject(score, result)
# 将自动驳回信息写入 result前端可据此展示
if auto_rejected:
result["ai_auto_rejected"] = True
result["ai_reject_reason"] = reject_reason
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
if auto_rejected:
task.stage = TaskStage.SCRIPT_UPLOAD
else:
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
if auto_rejected:
task.stage = TaskStage.VIDEO_UPLOAD
else:
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(Project.brand),
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,
project_id: Optional[str] = None,
) -> Tuple[List[Task], int]:
"""获取代理商的任务列表"""
query = (
select(Task)
.options(
selectinload(Task.project).selectinload(Project.brand),
selectinload(Task.agency),
selectinload(Task.creator),
)
.where(Task.agency_id == agency_id)
)
if stage:
query = query.where(Task.stage == stage)
if project_id:
query = query.where(Task.project_id == project_id)
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)
if project_id:
count_query = count_query.where(Task.project_id == project_id)
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,
project_id: Optional[str] = None,
) -> Tuple[List[Task], int]:
"""获取品牌方的任务列表(通过项目关联)"""
if project_id:
# 指定了项目 ID直接筛选该项目的任务
project_ids = [project_id]
else:
# 未指定项目,获取品牌方的所有项目
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(Project.brand),
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(Project.brand),
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(Project.brand),
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