feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -0,0 +1,46 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
*.env
|
||||
|
||||
# Temp files
|
||||
*.log
|
||||
*.tmp
|
||||
/tmp/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
@@ -0,0 +1,21 @@
|
||||
# 应用配置
|
||||
APP_NAME=秒思智能审核平台
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT 密钥 (生产环境必须更换)
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# AI 配置 (可选,也可通过 API 配置)
|
||||
AI_PROVIDER=doubao
|
||||
AI_API_KEY=
|
||||
AI_API_BASE_URL=
|
||||
|
||||
# 加密密钥 (生产环境必须更换,用于加密 API Key)
|
||||
ENCRYPTION_KEY=your-32-byte-encryption-key-here
|
||||
@@ -0,0 +1,30 @@
|
||||
# 基础镜像
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖 (FFmpeg 用于视频处理)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖文件
|
||||
COPY pyproject.toml .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建临时目录
|
||||
RUN mkdir -p /tmp/videos
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 默认命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Alembic 配置文件
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本目录
|
||||
script_location = alembic
|
||||
|
||||
# 版本位置模板
|
||||
# file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path 路径
|
||||
prepend_sys_path = .
|
||||
|
||||
# 时区
|
||||
# timezone =
|
||||
|
||||
# 版本文件格式
|
||||
version_path_separator = os
|
||||
|
||||
# 输出编码
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# 格式化迁移脚本
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -q
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
支持异步数据库迁移
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# 导入配置和模型
|
||||
from app.config import settings
|
||||
from app.models.base import Base
|
||||
from app.models import (
|
||||
Tenant,
|
||||
AIConfig,
|
||||
ReviewTask,
|
||||
ManualTask,
|
||||
ForbiddenWord,
|
||||
WhitelistItem,
|
||||
Competitor,
|
||||
RiskException,
|
||||
)
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 设置数据库 URL
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# 日志配置
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# MetaData 对象用于 autogenerate
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
不需要数据库连接,只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""执行迁移"""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""异步运行迁移"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
使用异步引擎连接数据库
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,217 @@
|
||||
"""初始表结构
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2024-01-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
platform_enum = postgresql.ENUM(
|
||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||
name='platform_enum'
|
||||
)
|
||||
platform_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
task_status_enum = postgresql.ENUM(
|
||||
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
||||
name='task_status_enum'
|
||||
)
|
||||
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
risk_target_type_enum = postgresql.ENUM(
|
||||
'influencer', 'order', 'content',
|
||||
name='risk_target_type_enum'
|
||||
)
|
||||
risk_target_type_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
risk_exception_status_enum = postgresql.ENUM(
|
||||
'pending', 'approved', 'rejected', 'expired', 'revoked',
|
||||
name='risk_exception_status_enum'
|
||||
)
|
||||
risk_exception_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# 租户表
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# AI 配置表
|
||||
op.create_table(
|
||||
'ai_configs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('provider', sa.String(50), nullable=False),
|
||||
sa.Column('base_url', sa.String(500), nullable=False),
|
||||
sa.Column('api_key_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('models', postgresql.JSONB(), nullable=False),
|
||||
sa.Column('temperature', sa.Float(), nullable=False, default=0.7),
|
||||
sa.Column('max_tokens', sa.Integer(), nullable=False, default=2000),
|
||||
sa.Column('available_models', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('last_test_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_test_result', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('is_configured', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_ai_configs_tenant_id', 'ai_configs', ['tenant_id'])
|
||||
|
||||
# 审核任务表
|
||||
op.create_table(
|
||||
'review_tasks',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('video_url', sa.String(2048), nullable=False),
|
||||
sa.Column('platform', platform_enum, nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('creator_id', sa.String(64), nullable=False),
|
||||
sa.Column('status', task_status_enum, nullable=False, default='pending'),
|
||||
sa.Column('progress', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('current_step', sa.String(100), nullable=False, default='等待处理'),
|
||||
sa.Column('score', sa.Integer(), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('violations', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('soft_warnings', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('requirements', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('competitors', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_review_tasks_tenant_id', 'review_tasks', ['tenant_id'])
|
||||
op.create_index('ix_review_tasks_brand_id', 'review_tasks', ['brand_id'])
|
||||
op.create_index('ix_review_tasks_creator_id', 'review_tasks', ['creator_id'])
|
||||
op.create_index('ix_review_tasks_status', 'review_tasks', ['status'])
|
||||
|
||||
# 人工任务表
|
||||
op.create_table(
|
||||
'manual_tasks',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('review_task_id', sa.String(64), sa.ForeignKey('review_tasks.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('video_url', sa.String(2048), nullable=False),
|
||||
sa.Column('platform', platform_enum, nullable=False),
|
||||
sa.Column('creator_id', sa.String(64), nullable=False),
|
||||
sa.Column('status', task_status_enum, nullable=False, default='pending'),
|
||||
sa.Column('approve_comment', sa.Text(), nullable=True),
|
||||
sa.Column('reject_reason', sa.Text(), nullable=True),
|
||||
sa.Column('reject_violations', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('reviewer_id', sa.String(64), nullable=True),
|
||||
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_manual_tasks_tenant_id', 'manual_tasks', ['tenant_id'])
|
||||
op.create_index('ix_manual_tasks_review_task_id', 'manual_tasks', ['review_task_id'])
|
||||
op.create_index('ix_manual_tasks_creator_id', 'manual_tasks', ['creator_id'])
|
||||
op.create_index('ix_manual_tasks_status', 'manual_tasks', ['status'])
|
||||
|
||||
# 违禁词表
|
||||
op.create_table(
|
||||
'forbidden_words',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('word', sa.String(255), nullable=False),
|
||||
sa.Column('category', sa.String(100), nullable=False),
|
||||
sa.Column('severity', sa.String(50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_forbidden_words_tenant_id', 'forbidden_words', ['tenant_id'])
|
||||
op.create_index('ix_forbidden_words_word', 'forbidden_words', ['word'])
|
||||
op.create_index('ix_forbidden_words_category', 'forbidden_words', ['category'])
|
||||
|
||||
# 白名单表
|
||||
op.create_table(
|
||||
'whitelist_items',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('term', sa.String(255), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_whitelist_items_tenant_id', 'whitelist_items', ['tenant_id'])
|
||||
op.create_index('ix_whitelist_items_brand_id', 'whitelist_items', ['brand_id'])
|
||||
op.create_index('ix_whitelist_items_term', 'whitelist_items', ['term'])
|
||||
|
||||
# 竞品表
|
||||
op.create_table(
|
||||
'competitors',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('logo_url', sa.String(2048), nullable=True),
|
||||
sa.Column('keywords', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_competitors_tenant_id', 'competitors', ['tenant_id'])
|
||||
op.create_index('ix_competitors_brand_id', 'competitors', ['brand_id'])
|
||||
|
||||
# 特例审批表
|
||||
op.create_table(
|
||||
'risk_exceptions',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('applicant_id', sa.String(64), nullable=False),
|
||||
sa.Column('apply_time', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('target_type', risk_target_type_enum, nullable=False),
|
||||
sa.Column('target_id', sa.String(64), nullable=False),
|
||||
sa.Column('risk_rule_id', sa.String(64), nullable=False),
|
||||
sa.Column('status', risk_exception_status_enum, nullable=False, default='pending'),
|
||||
sa.Column('valid_start_time', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('valid_end_time', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('reason_category', sa.String(100), nullable=False),
|
||||
sa.Column('justification', sa.Text(), nullable=False),
|
||||
sa.Column('attachment_url', sa.String(2048), nullable=True),
|
||||
sa.Column('current_approver_id', sa.String(64), nullable=True),
|
||||
sa.Column('approval_chain_log', postgresql.JSONB(), nullable=False, server_default='[]'),
|
||||
sa.Column('auto_rejected', sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column('rejection_reason', sa.Text(), nullable=True),
|
||||
sa.Column('last_status_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_risk_exceptions_tenant_id', 'risk_exceptions', ['tenant_id'])
|
||||
op.create_index('ix_risk_exceptions_applicant_id', 'risk_exceptions', ['applicant_id'])
|
||||
op.create_index('ix_risk_exceptions_target_id', 'risk_exceptions', ['target_id'])
|
||||
op.create_index('ix_risk_exceptions_status', 'risk_exceptions', ['status'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除表
|
||||
op.drop_table('risk_exceptions')
|
||||
op.drop_table('competitors')
|
||||
op.drop_table('whitelist_items')
|
||||
op.drop_table('forbidden_words')
|
||||
op.drop_table('manual_tasks')
|
||||
op.drop_table('review_tasks')
|
||||
op.drop_table('ai_configs')
|
||||
op.drop_table('tenants')
|
||||
|
||||
# 删除枚举类型
|
||||
op.execute('DROP TYPE IF EXISTS risk_exception_status_enum')
|
||||
op.execute('DROP TYPE IF EXISTS risk_target_type_enum')
|
||||
op.execute('DROP TYPE IF EXISTS task_status_enum')
|
||||
op.execute('DROP TYPE IF EXISTS platform_enum')
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Add manual task script/video upload fields
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-02-04
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=True,
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_content", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_file_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("manual_tasks", "script_uploaded_at")
|
||||
op.drop_column("manual_tasks", "script_file_url")
|
||||
op.drop_column("manual_tasks", "script_content")
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("manual_tasks", "video_uploaded_at")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""秒思智能审核平台后端服务"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由模块"""
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
AI 服务配置 API
|
||||
品牌方管理 AI 提供商配置、模型选择、连通性测试
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.ai_config import (
|
||||
AIProvider,
|
||||
AIConfigUpdate,
|
||||
AIConfigResponse,
|
||||
AIModelsConfig,
|
||||
AIParametersConfig,
|
||||
GetModelsRequest,
|
||||
TestConnectionRequest,
|
||||
ModelsListResponse,
|
||||
ConnectionTestResponse,
|
||||
ModelTestResult,
|
||||
ModelInfo,
|
||||
ModelCapability,
|
||||
mask_api_key,
|
||||
)
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
router = APIRouter(prefix="/ai-config", tags=["ai-config"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("", response_model=AIConfigResponse)
|
||||
async def get_ai_config(
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
获取当前 AI 配置
|
||||
|
||||
- 未配置返回 404
|
||||
- 已配置返回配置信息(API Key 脱敏)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == x_tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="AI 服务未配置,请先完成配置",
|
||||
)
|
||||
|
||||
# 解密 API Key 用于脱敏显示
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=config.is_configured,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=AIConfigResponse)
|
||||
async def update_ai_config(
|
||||
request: AIConfigUpdate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
更新 AI 配置
|
||||
|
||||
- 保存提供商、连接信息、模型配置
|
||||
- API Key 加密存储
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 加密 API Key
|
||||
api_key_encrypted = encrypt_api_key(request.api_key)
|
||||
|
||||
# 创建或更新配置
|
||||
config = await AIServiceFactory.create_or_update_config(
|
||||
tenant_id=x_tenant_id,
|
||||
provider=request.provider.value,
|
||||
base_url=request.base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=request.models.model_dump(),
|
||||
temperature=request.parameters.temperature,
|
||||
max_tokens=request.parameters.max_tokens,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(request.api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=True,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelsListResponse)
|
||||
async def get_available_models(
|
||||
request: GetModelsRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ModelsListResponse:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
- 调用提供商 API 获取模型列表
|
||||
- 按能力分类(text/vision/audio)
|
||||
"""
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
models_dict = await client.list_models()
|
||||
await client.close()
|
||||
|
||||
# 转换为 ModelInfo 对象
|
||||
models = {
|
||||
k: [ModelInfo(**m) for m in v]
|
||||
for k, v in models_dict.items()
|
||||
}
|
||||
|
||||
# 更新配置中的可用模型缓存
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.available_models = models_dict
|
||||
await db.flush()
|
||||
|
||||
return ModelsListResponse(
|
||||
success=True,
|
||||
models=models,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"获取模型列表失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=ConnectionTestResponse)
|
||||
async def test_connection(
|
||||
request: TestConnectionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConnectionTestResponse:
|
||||
"""
|
||||
测试 AI 服务连接
|
||||
|
||||
- 并行测试三个模型
|
||||
- 返回每个模型的测试结果
|
||||
"""
|
||||
client = None
|
||||
models = request.models.model_dump()
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
# 定义模型能力映射
|
||||
capability_map = {
|
||||
"text": ModelCapability.TEXT,
|
||||
"vision": ModelCapability.VISION,
|
||||
"audio": ModelCapability.AUDIO,
|
||||
}
|
||||
|
||||
async def test_single(model_type: str, model_id: str) -> tuple[str, ModelTestResult]:
|
||||
capability = capability_map.get(model_type, ModelCapability.TEXT)
|
||||
result = await client.test_connection(model_id, capability)
|
||||
return model_type, ModelTestResult(
|
||||
success=result.success,
|
||||
latency_ms=result.latency_ms,
|
||||
error=result.error,
|
||||
model=model_id,
|
||||
)
|
||||
|
||||
# 并行测试所有模型
|
||||
tasks = [
|
||||
test_single(model_type, model_id)
|
||||
for model_type, model_id in models.items()
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks)
|
||||
results = {model_type: result for model_type, result in results_list}
|
||||
|
||||
# 计算测试结果
|
||||
all_success = all(r.success for r in results.values())
|
||||
failed_count = sum(1 for r in results.values() if not r.success)
|
||||
|
||||
if all_success:
|
||||
message = "所有模型连接成功"
|
||||
else:
|
||||
message = f"{failed_count} 个模型连接失败,请检查模型名称或 API 权限"
|
||||
|
||||
response = ConnectionTestResponse(
|
||||
success=all_success,
|
||||
results=results,
|
||||
message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 确保接口返回 200,并返回失败详情
|
||||
results = {
|
||||
model_type: ModelTestResult(
|
||||
success=False,
|
||||
latency_ms=0,
|
||||
error=str(exc),
|
||||
model=model_id,
|
||||
)
|
||||
for model_type, model_id in models.items()
|
||||
}
|
||||
response = ConnectionTestResponse(
|
||||
success=False,
|
||||
results=results,
|
||||
message=f"连接测试失败: {str(exc)}",
|
||||
)
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 保存测试结果到数据库
|
||||
db_result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = db_result.scalar_one_or_none()
|
||||
if config:
|
||||
config.last_test_at = datetime.now(timezone.utc)
|
||||
config.last_test_result = {
|
||||
k: v.model_dump() for k, v in response.results.items()
|
||||
}
|
||||
await db.flush()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== 供其他模块调用 ====================
|
||||
|
||||
async def get_ai_config_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[dict]:
|
||||
"""获取租户的 AI 配置(供审核服务调用)"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tenant_id": config.tenant_id,
|
||||
"provider": config.provider,
|
||||
"base_url": config.base_url,
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"models": config.models,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""健康检查 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
from app.services.health import HealthChecker, get_health_checker
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
健康检查端点
|
||||
|
||||
Returns:
|
||||
dict: 包含服务状态信息
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness_check(
|
||||
health_checker: HealthChecker = Depends(get_health_checker),
|
||||
):
|
||||
"""
|
||||
就绪检查端点(用于 K8s)
|
||||
检查数据库、Redis 等依赖服务是否就绪
|
||||
|
||||
Returns:
|
||||
dict: 服务就绪状态和依赖检查结果
|
||||
"""
|
||||
checks = await health_checker.check_all()
|
||||
all_ready = all(checks.values())
|
||||
|
||||
return {
|
||||
"ready": all_ready,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def liveness_check():
|
||||
"""
|
||||
存活检查端点(用于 K8s)
|
||||
只检查服务进程是否存活,不检查依赖
|
||||
|
||||
Returns:
|
||||
dict: 服务存活状态
|
||||
"""
|
||||
return {"alive": True}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
一致性指标 API
|
||||
按达人、规则类型、时间窗口查询
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.schemas.review import (
|
||||
ConsistencyMetricsResponse,
|
||||
ConsistencyWindow,
|
||||
RuleConsistencyMetric,
|
||||
ViolationType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/consistency", response_model=ConsistencyMetricsResponse)
|
||||
async def get_consistency_metrics(
|
||||
influencer_id: str = Query(None, description="达人 ID(必填)"),
|
||||
window: ConsistencyWindow = Query(ConsistencyWindow.ROLLING_30D, description="计算周期"),
|
||||
rule_type: ViolationType = Query(None, description="规则类型筛选"),
|
||||
) -> ConsistencyMetricsResponse:
|
||||
"""
|
||||
查询一致性指标
|
||||
|
||||
- 按达人 ID 查询
|
||||
- 支持 Rolling 30 天、周度快照、月度快照
|
||||
- 可按规则类型筛选
|
||||
"""
|
||||
# 验证必填参数
|
||||
if not influencer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="缺少必填参数: influencer_id",
|
||||
)
|
||||
|
||||
# 计算时间范围
|
||||
now = datetime.now(timezone.utc)
|
||||
if window == ConsistencyWindow.ROLLING_30D:
|
||||
period_start = now - timedelta(days=30)
|
||||
period_end = now
|
||||
elif window == ConsistencyWindow.SNAPSHOT_WEEK:
|
||||
# 本周一到现在
|
||||
days_since_monday = now.weekday()
|
||||
period_start = (now - timedelta(days=days_since_monday)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
period_end = now
|
||||
else: # SNAPSHOT_MONTH
|
||||
# 本月1号到现在
|
||||
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
period_end = now
|
||||
|
||||
# 生成模拟数据(实际应从数据库查询)
|
||||
all_metrics = [
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.FORBIDDEN_WORD,
|
||||
total_reviews=100,
|
||||
violation_count=5,
|
||||
violation_rate=0.05,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.COMPETITOR_LOGO,
|
||||
total_reviews=100,
|
||||
violation_count=2,
|
||||
violation_rate=0.02,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.DURATION_SHORT,
|
||||
total_reviews=100,
|
||||
violation_count=8,
|
||||
violation_rate=0.08,
|
||||
),
|
||||
]
|
||||
|
||||
# 按规则类型筛选
|
||||
if rule_type:
|
||||
all_metrics = [m for m in all_metrics if m.rule_type == rule_type]
|
||||
|
||||
return ConsistencyMetricsResponse(
|
||||
influencer_id=influencer_id,
|
||||
window=window,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
metrics=all_metrics,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
特例审批 API
|
||||
创建、查询、审批特例记录
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.risk_exception import (
|
||||
RiskException,
|
||||
RiskTargetType as DBRiskTargetType,
|
||||
RiskExceptionStatus as DBRiskExceptionStatus,
|
||||
)
|
||||
from app.schemas.review import (
|
||||
RiskExceptionCreateRequest,
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
RiskExceptionDecisionRequest,
|
||||
RiskTargetType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/risk-exceptions", tags=["risk-exceptions"])
|
||||
|
||||
|
||||
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 _exception_to_response(record: RiskException) -> RiskExceptionRecord:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return RiskExceptionRecord(
|
||||
record_id=record.id,
|
||||
applicant_id=record.applicant_id,
|
||||
apply_time=record.apply_time,
|
||||
target_type=RiskTargetType(record.target_type.value),
|
||||
target_id=record.target_id,
|
||||
risk_rule_id=record.risk_rule_id,
|
||||
status=RiskExceptionStatus(record.status.value),
|
||||
valid_start_time=record.valid_start_time,
|
||||
valid_end_time=record.valid_end_time,
|
||||
reason_category=record.reason_category,
|
||||
justification=record.justification,
|
||||
attachment_url=record.attachment_url,
|
||||
current_approver_id=record.current_approver_id,
|
||||
approval_chain_log=record.approval_chain_log or [],
|
||||
auto_rejected=record.auto_rejected,
|
||||
rejection_reason=record.rejection_reason,
|
||||
last_status_at=record.last_status_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RiskExceptionRecord, status_code=status.HTTP_201_CREATED)
|
||||
async def create_exception(
|
||||
request: RiskExceptionCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""创建特例申请"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
record_id = f"exc-{uuid.uuid4().hex[:12]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
record = RiskException(
|
||||
id=record_id,
|
||||
tenant_id=x_tenant_id,
|
||||
applicant_id=request.applicant_id,
|
||||
apply_time=now,
|
||||
target_type=DBRiskTargetType(request.target_type.value),
|
||||
target_id=request.target_id,
|
||||
risk_rule_id=request.risk_rule_id,
|
||||
status=DBRiskExceptionStatus.PENDING,
|
||||
valid_start_time=request.valid_start_time,
|
||||
valid_end_time=request.valid_end_time,
|
||||
reason_category=request.reason_category,
|
||||
justification=request.justification,
|
||||
attachment_url=request.attachment_url,
|
||||
current_approver_id=request.current_approver_id,
|
||||
approval_chain_log=[],
|
||||
auto_rejected=False,
|
||||
rejection_reason=None,
|
||||
last_status_at=now,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.get("/{record_id}", response_model=RiskExceptionRecord)
|
||||
async def get_exception(
|
||||
record_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""查询特例记录"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/approve", response_model=RiskExceptionRecord)
|
||||
async def approve_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""审批通过"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.APPROVED
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "approve",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/reject", response_model=RiskExceptionRecord)
|
||||
async def reject_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""驳回申请"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
# 驳回必须填写原因
|
||||
if not request.comment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="驳回必须填写原因",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.REJECTED
|
||||
record.rejection_reason = request.comment
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "reject",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
规则管理 API
|
||||
违禁词库、白名单、竞品库、平台规则
|
||||
"""
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
|
||||
# ==================== 请求/响应模型 ====================
|
||||
|
||||
class ForbiddenWordCreate(BaseModel):
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordResponse(BaseModel):
|
||||
id: str
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordListResponse(BaseModel):
|
||||
items: list[ForbiddenWordResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WhitelistCreate(BaseModel):
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistResponse(BaseModel):
|
||||
id: str
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistListResponse(BaseModel):
|
||||
items: list[WhitelistResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CompetitorCreate(BaseModel):
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorListResponse(BaseModel):
|
||||
items: list[CompetitorResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PlatformRuleResponse(BaseModel):
|
||||
platform: str
|
||||
rules: list[dict]
|
||||
version: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PlatformListResponse(BaseModel):
|
||||
items: list[PlatformRuleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class RuleValidateRequest(BaseModel):
|
||||
brand_id: str
|
||||
platform: str
|
||||
brief_rules: dict
|
||||
|
||||
|
||||
class RuleConflict(BaseModel):
|
||||
brief_rule: str
|
||||
platform_rule: str
|
||||
suggestion: str
|
||||
|
||||
|
||||
class RuleValidateResponse(BaseModel):
|
||||
conflicts: list[RuleConflict]
|
||||
|
||||
|
||||
# ==================== 预置平台规则 ====================
|
||||
|
||||
_platform_rules = {
|
||||
"douyin": {
|
||||
"platform": "douyin",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一", "最佳", "绝对", "100%"]},
|
||||
{"type": "duration", "min_seconds": 7},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-15T00:00:00Z",
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"platform": "xiaohongshu",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-10T00:00:00Z",
|
||||
},
|
||||
"bilibili": {
|
||||
"platform": "bilibili",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-12T00:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ==================== 违禁词库 ====================
|
||||
|
||||
@router.get("/forbidden-words", response_model=ForbiddenWordListResponse)
|
||||
async def list_forbidden_words(
|
||||
category: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordListResponse:
|
||||
"""查询违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == x_tenant_id)
|
||||
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return ForbiddenWordListResponse(
|
||||
items=[
|
||||
ForbiddenWordResponse(
|
||||
id=w.id,
|
||||
word=w.word,
|
||||
category=w.category,
|
||||
severity=w.severity,
|
||||
)
|
||||
for w in words
|
||||
],
|
||||
total=len(words),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/forbidden-words",
|
||||
response_model=ForbiddenWordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_forbidden_word(
|
||||
request: ForbiddenWordCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordResponse:
|
||||
"""添加违禁词"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 检查重复
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
ForbiddenWord.word == request.word,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"违禁词已存在: {request.word}",
|
||||
)
|
||||
|
||||
word_id = f"fw-{uuid.uuid4().hex[:8]}"
|
||||
word = ForbiddenWord(
|
||||
id=word_id,
|
||||
tenant_id=x_tenant_id,
|
||||
word=request.word,
|
||||
category=request.category,
|
||||
severity=request.severity,
|
||||
)
|
||||
db.add(word)
|
||||
await db.flush()
|
||||
|
||||
return ForbiddenWordResponse(
|
||||
id=word.id,
|
||||
word=word.word,
|
||||
category=word.category,
|
||||
severity=word.severity,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/forbidden-words/{word_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_forbidden_word(
|
||||
word_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除违禁词"""
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.id == word_id,
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
word = result.scalar_one_or_none()
|
||||
|
||||
if not word:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"违禁词不存在: {word_id}",
|
||||
)
|
||||
|
||||
await db.delete(word)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 白名单 ====================
|
||||
|
||||
@router.get("/whitelist", response_model=WhitelistListResponse)
|
||||
async def list_whitelist(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistListResponse:
|
||||
"""查询白名单"""
|
||||
query = select(WhitelistItem).where(WhitelistItem.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(WhitelistItem.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return WhitelistListResponse(
|
||||
items=[
|
||||
WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/whitelist",
|
||||
response_model=WhitelistResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_to_whitelist(
|
||||
request: WhitelistCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistResponse:
|
||||
"""添加白名单"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
item_id = f"wl-{uuid.uuid4().hex[:8]}"
|
||||
item = WhitelistItem(
|
||||
id=item_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
term=request.term,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
|
||||
return WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 竞品库 ====================
|
||||
|
||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||
async def list_competitors(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorListResponse:
|
||||
"""查询竞品列表"""
|
||||
query = select(Competitor).where(Competitor.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(Competitor.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
competitors = result.scalars().all()
|
||||
|
||||
return CompetitorListResponse(
|
||||
items=[
|
||||
CompetitorResponse(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
brand_id=c.brand_id,
|
||||
logo_url=c.logo_url,
|
||||
keywords=c.keywords or [],
|
||||
)
|
||||
for c in competitors
|
||||
],
|
||||
total=len(competitors),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/competitors",
|
||||
response_model=CompetitorResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_competitor(
|
||||
request: CompetitorCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorResponse:
|
||||
"""添加竞品"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
comp_id = f"comp-{uuid.uuid4().hex[:8]}"
|
||||
competitor = Competitor(
|
||||
id=comp_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
name=request.name,
|
||||
logo_url=request.logo_url,
|
||||
keywords=request.keywords,
|
||||
)
|
||||
db.add(competitor)
|
||||
await db.flush()
|
||||
|
||||
return CompetitorResponse(
|
||||
id=competitor.id,
|
||||
name=competitor.name,
|
||||
brand_id=competitor.brand_id,
|
||||
logo_url=competitor.logo_url,
|
||||
keywords=competitor.keywords or [],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/competitors/{competitor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_competitor(
|
||||
competitor_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除竞品"""
|
||||
result = await db.execute(
|
||||
select(Competitor).where(
|
||||
and_(
|
||||
Competitor.id == competitor_id,
|
||||
Competitor.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
competitor = result.scalar_one_or_none()
|
||||
|
||||
if not competitor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"竞品不存在: {competitor_id}",
|
||||
)
|
||||
|
||||
await db.delete(competitor)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 平台规则 ====================
|
||||
|
||||
@router.get("/platforms", response_model=PlatformListResponse)
|
||||
async def list_platform_rules() -> PlatformListResponse:
|
||||
"""查询所有平台规则"""
|
||||
return PlatformListResponse(
|
||||
items=[PlatformRuleResponse(**r) for r in _platform_rules.values()],
|
||||
total=len(_platform_rules),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platforms/{platform}", response_model=PlatformRuleResponse)
|
||||
async def get_platform_rules(platform: str) -> PlatformRuleResponse:
|
||||
"""查询指定平台规则"""
|
||||
if platform not in _platform_rules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"平台不存在: {platform}",
|
||||
)
|
||||
return PlatformRuleResponse(**_platform_rules[platform])
|
||||
|
||||
|
||||
# ==================== 规则冲突检测 ====================
|
||||
|
||||
@router.post("/validate", response_model=RuleValidateResponse)
|
||||
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
conflicts = []
|
||||
|
||||
platform_rule = _platform_rules.get(request.platform)
|
||||
if not platform_rule:
|
||||
return RuleValidateResponse(conflicts=[])
|
||||
|
||||
# 检查 required_phrases 是否包含违禁词
|
||||
required_phrases = request.brief_rules.get("required_phrases", [])
|
||||
platform_forbidden = []
|
||||
for rule in platform_rule.get("rules", []):
|
||||
if rule.get("type") == "forbidden_word":
|
||||
platform_forbidden.extend(rule.get("words", []))
|
||||
|
||||
for phrase in required_phrases:
|
||||
for word in platform_forbidden:
|
||||
if word in phrase:
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"要求使用:{phrase}",
|
||||
platform_rule=f"平台禁止:{word}",
|
||||
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
|
||||
))
|
||||
|
||||
return RuleValidateResponse(conflicts=conflicts)
|
||||
|
||||
|
||||
# ==================== 辅助函数(供其他模块调用) ====================
|
||||
|
||||
async def get_whitelist_for_brand(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[str]:
|
||||
"""获取品牌白名单词汇"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [item.term for item in items]
|
||||
|
||||
|
||||
async def get_other_brands_whitelist_terms(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""
|
||||
获取其他品牌的白名单词汇(用于品牌安全检测)
|
||||
|
||||
Returns:
|
||||
list of (term, owner_brand_id)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id != brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [(item.term, item.brand_id) for item in items]
|
||||
|
||||
|
||||
async def get_forbidden_words_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
category: str = None,
|
||||
) -> list[dict]:
|
||||
"""获取租户的违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == tenant_id)
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"word": w.word,
|
||||
"category": w.category,
|
||||
"severity": w.severity,
|
||||
}
|
||||
for w in words
|
||||
]
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
脚本预审 API
|
||||
"""
|
||||
import re
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.review import (
|
||||
ScriptReviewRequest,
|
||||
ScriptReviewResponse,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
Position,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.api.rules import (
|
||||
get_whitelist_for_brand,
|
||||
get_other_brands_whitelist_terms,
|
||||
get_forbidden_words_for_tenant,
|
||||
)
|
||||
from app.services.soft_risk import evaluate_soft_risk
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
|
||||
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||
|
||||
# 内置违禁词库(广告极限词)
|
||||
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
||||
|
||||
# 功效词库(医疗/功效宣称)
|
||||
EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
||||
|
||||
# 广告语境关键词(用于判断是否为广告场景)
|
||||
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
||||
|
||||
|
||||
def _is_ad_context(content: str, word: str) -> bool:
|
||||
"""
|
||||
判断是否为广告语境
|
||||
|
||||
规则:
|
||||
- 如果内容中包含广告关键词,认为是广告语境
|
||||
- 如果违禁词出现在明显的非广告句式中,不是广告语境
|
||||
"""
|
||||
# 非广告语境模式
|
||||
non_ad_patterns = [
|
||||
r"他是第一[个名位]", # 他是第一个/名
|
||||
r"[是为]第一[个名位]", # 是第一个
|
||||
r"最开心|最高兴|最难忘", # 情感表达
|
||||
r"第一[次个].*[到来抵达]", # 第一次到达
|
||||
]
|
||||
|
||||
for pattern in non_ad_patterns:
|
||||
if re.search(pattern, content):
|
||||
return False
|
||||
|
||||
# 检查是否包含广告关键词
|
||||
return any(kw in content for kw in AD_CONTEXT_KEYWORDS)
|
||||
|
||||
|
||||
def _check_selling_point_coverage(content: str, required_points: list[str]) -> list[str]:
|
||||
"""
|
||||
检查卖点覆盖情况
|
||||
|
||||
使用语义匹配而非精确匹配
|
||||
"""
|
||||
missing = []
|
||||
|
||||
# 卖点关键词映射
|
||||
point_keywords = {
|
||||
"品牌名称": ["品牌", "牌子", "品牌A", "品牌B"],
|
||||
"使用方法": ["使用", "用法", "早晚", "每天", "一次", "涂抹", "喷洒"],
|
||||
"功效说明": ["功效", "效果", "水润", "美白", "保湿", "滋润", "改善"],
|
||||
}
|
||||
|
||||
for point in required_points:
|
||||
# 精确匹配
|
||||
if point in content:
|
||||
continue
|
||||
|
||||
# 关键词匹配
|
||||
keywords = point_keywords.get(point, [])
|
||||
if any(kw in content for kw in keywords):
|
||||
continue
|
||||
|
||||
missing.append(point)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
@router.post("/review", response_model=ScriptReviewResponse)
|
||||
async def review_script(
|
||||
request: ScriptReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ScriptReviewResponse:
|
||||
"""
|
||||
脚本预审
|
||||
|
||||
- 检测违禁词(支持语境感知)
|
||||
- 检测功效词
|
||||
- 检查必要卖点
|
||||
- 应用白名单
|
||||
- 可选 AI 深度分析
|
||||
- 返回合规分数和修改建议
|
||||
"""
|
||||
violations = []
|
||||
content = request.content
|
||||
|
||||
# 获取品牌白名单
|
||||
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
||||
|
||||
# 获取租户自定义违禁词
|
||||
tenant_forbidden_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
||||
|
||||
# 1. 违禁词检测(广告极限词)
|
||||
all_forbidden_words = ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]
|
||||
|
||||
for word in all_forbidden_words:
|
||||
# 白名单跳过
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
# 语境感知:非广告语境跳过
|
||||
if not _is_ad_context(content, word):
|
||||
start = pos + 1
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.FORBIDDEN_WORD,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"建议删除或替换违禁词:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 2. 功效词检测
|
||||
for word in EFFICACY_WORDS:
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.EFFICACY_CLAIM,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"功效宣称词违反广告法,建议删除:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 3. 检测其他品牌专属词(品牌安全风险)
|
||||
other_brand_terms = await get_other_brands_whitelist_terms(x_tenant_id, request.brand_id, db)
|
||||
for term, owner_brand in other_brand_terms:
|
||||
if term in content:
|
||||
violations.append(Violation(
|
||||
type=ViolationType.BRAND_SAFETY,
|
||||
content=term,
|
||||
severity=RiskLevel.MEDIUM,
|
||||
suggestion=f"使用了其他品牌的专属词汇:{term}",
|
||||
position=Position(start=content.find(term), end=content.find(term) + len(term)),
|
||||
))
|
||||
|
||||
# 4. 检查遗漏卖点
|
||||
missing_points: list[str] | None = None
|
||||
if request.required_points:
|
||||
missing = _check_selling_point_coverage(content, request.required_points)
|
||||
missing_points = missing if missing else []
|
||||
|
||||
# 5. 可选:AI 深度分析
|
||||
ai_violations = await _ai_deep_analysis(x_tenant_id, content, db)
|
||||
if ai_violations:
|
||||
violations.extend(ai_violations)
|
||||
|
||||
# 6. 计算分数
|
||||
score = 100 - len(violations) * 25
|
||||
if missing_points:
|
||||
score -= len(missing_points) * 5
|
||||
score = max(0, score)
|
||||
|
||||
# 7. 生成摘要
|
||||
parts = []
|
||||
if violations:
|
||||
parts.append(f"发现 {len(violations)} 处违规")
|
||||
if missing_points:
|
||||
parts.append(f"遗漏 {len(missing_points)} 个卖点")
|
||||
|
||||
if not parts:
|
||||
summary = "脚本内容合规,未发现问题"
|
||||
else:
|
||||
summary = ",".join(parts)
|
||||
|
||||
# 8. 软性风控评估
|
||||
soft_warnings: list[SoftRiskWarning] = []
|
||||
if request.soft_risk_context:
|
||||
soft_warnings = evaluate_soft_risk(request.soft_risk_context)
|
||||
|
||||
return ScriptReviewResponse(
|
||||
score=score,
|
||||
summary=summary,
|
||||
violations=violations,
|
||||
missing_points=missing_points,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
async def _ai_deep_analysis(
|
||||
tenant_id: str,
|
||||
content: str,
|
||||
db: AsyncSession,
|
||||
) -> list[Violation]:
|
||||
"""
|
||||
使用 AI 进行深度分析
|
||||
|
||||
AI 分析失败时返回空列表,降级到规则检测
|
||||
"""
|
||||
try:
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
if not ai_client:
|
||||
return []
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
if not config:
|
||||
return []
|
||||
|
||||
text_model = config.models.get("text", "gpt-4o")
|
||||
|
||||
# 构建分析提示
|
||||
analysis_prompt = f"""作为广告合规审核专家,请分析以下广告脚本内容,检测潜在的合规风险:
|
||||
|
||||
脚本内容:
|
||||
{content}
|
||||
|
||||
请检查以下方面:
|
||||
1. 是否存在隐性的虚假宣传(如暗示疗效但不直接说明)
|
||||
2. 是否存在容易引起误解的表述
|
||||
3. 是否存在夸大描述
|
||||
4. 是否存在可能违反广告法的其他内容
|
||||
|
||||
如果发现问题,请以 JSON 数组格式返回,每项包含:
|
||||
- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety)
|
||||
- content: 违规内容
|
||||
- severity: 严重程度 (high/medium/low)
|
||||
- suggestion: 修改建议
|
||||
|
||||
如果未发现问题,返回空数组 []
|
||||
|
||||
请只返回 JSON 数组,不要包含其他内容。"""
|
||||
|
||||
response = await ai_client.chat_completion(
|
||||
messages=[{"role": "user", "content": analysis_prompt}],
|
||||
model=text_model,
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
# 解析 AI 响应
|
||||
import json
|
||||
try:
|
||||
# 清理响应内容(移除可能的 markdown 标记)
|
||||
response_content = response.content.strip()
|
||||
if response_content.startswith("```"):
|
||||
response_content = response_content.split("\n", 1)[1]
|
||||
if response_content.endswith("```"):
|
||||
response_content = response_content.rsplit("\n", 1)[0]
|
||||
|
||||
ai_results = json.loads(response_content)
|
||||
|
||||
violations = []
|
||||
for item in ai_results:
|
||||
violation_type = item.get("type", "forbidden_word")
|
||||
if violation_type == "forbidden_word":
|
||||
vtype = ViolationType.FORBIDDEN_WORD
|
||||
elif violation_type == "efficacy_claim":
|
||||
vtype = ViolationType.EFFICACY_CLAIM
|
||||
else:
|
||||
vtype = ViolationType.BRAND_SAFETY
|
||||
|
||||
severity = item.get("severity", "medium")
|
||||
if severity == "high":
|
||||
slevel = RiskLevel.HIGH
|
||||
elif severity == "low":
|
||||
slevel = RiskLevel.LOW
|
||||
else:
|
||||
slevel = RiskLevel.MEDIUM
|
||||
|
||||
violations.append(Violation(
|
||||
type=vtype,
|
||||
content=item.get("content", ""),
|
||||
severity=slevel,
|
||||
suggestion=item.get("suggestion", "建议修改"),
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,返回空列表
|
||||
return []
|
||||
|
||||
except Exception:
|
||||
# AI 调用失败,降级到规则检测
|
||||
return []
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
审核任务 API
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 (
|
||||
TaskCreateRequest,
|
||||
TaskResponse,
|
||||
TaskListResponse,
|
||||
TaskScriptUploadRequest,
|
||||
TaskVideoUploadRequest,
|
||||
TaskApproveRequest,
|
||||
TaskRejectRequest,
|
||||
TaskStatus,
|
||||
Platform,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
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:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return TaskResponse(
|
||||
task_id=task.id,
|
||||
video_url=task.video_url,
|
||||
script_content=task.script_content,
|
||||
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 "",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: TaskCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
创建审核任务
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
task_id = f"task-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
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),
|
||||
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,
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
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"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskListResponse:
|
||||
"""
|
||||
查询任务列表
|
||||
|
||||
支持分页和筛选
|
||||
"""
|
||||
# 构建查询
|
||||
query = select(ManualTask).where(ManualTask.tenant_id == x_tenant_id)
|
||||
|
||||
if task_status:
|
||||
query = query.where(ManualTask.status == DBTaskStatus(task_status.value))
|
||||
|
||||
if platform:
|
||||
query = query.where(ManualTask.platform == DBPlatform(platform.value))
|
||||
|
||||
# 按创建时间倒序排列
|
||||
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()
|
||||
|
||||
return TaskListResponse(
|
||||
items=[_task_to_response(t) for t in tasks],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@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"),
|
||||
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.status = DBTaskStatus.APPROVED
|
||||
task.approve_comment = request.comment
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
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()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.status = DBTaskStatus.REJECTED
|
||||
task.reject_reason = request.reason
|
||||
task.reject_violations = request.violations
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
视频审核 API
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
VideoReviewRequest,
|
||||
VideoReviewSubmitResponse,
|
||||
VideoReviewProgressResponse,
|
||||
VideoReviewResultResponse,
|
||||
TaskStatus,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
ViolationSource,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review",
|
||||
response_model=VideoReviewSubmitResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def submit_video_review(
|
||||
request: VideoReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewSubmitResponse:
|
||||
"""
|
||||
提交视频审核
|
||||
|
||||
返回 202 Accepted,异步处理
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
review_id = f"review-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# 创建审核任务
|
||||
task = ReviewTask(
|
||||
id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
platform=DBPlatform(request.platform.value),
|
||||
brand_id=request.brand_id,
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
progress=0,
|
||||
current_step="等待处理",
|
||||
competitors=request.competitors,
|
||||
requirements=request.requirements,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 触发 Celery 异步任务
|
||||
try:
|
||||
from app.tasks.review import process_video_review_task
|
||||
process_video_review_task.delay(
|
||||
review_id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
brand_id=request.brand_id,
|
||||
platform=request.platform.value,
|
||||
)
|
||||
except Exception:
|
||||
# Celery 不可用时,任务保持 PENDING 状态
|
||||
# 后续可通过定时任务或手动触发处理
|
||||
pass
|
||||
|
||||
return VideoReviewSubmitResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/{review_id}/progress",
|
||||
response_model=VideoReviewProgressResponse,
|
||||
)
|
||||
async def get_review_progress(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewProgressResponse:
|
||||
"""
|
||||
查询审核进度
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.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"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
return VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{review_id}/result")
|
||||
async def get_review_result(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查询审核结果
|
||||
|
||||
- 未完成:返回 202 + 进度结构
|
||||
- 已完成:返回 200 + 结果结构
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.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"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
# 未完成:返回 202 + 进度
|
||||
if task.status in [DBTaskStatus.PENDING, DBTaskStatus.PROCESSING]:
|
||||
progress_response = VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
content=progress_response.model_dump(),
|
||||
)
|
||||
|
||||
# 失败:返回错误信息
|
||||
if task.status == DBTaskStatus.FAILED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task.error_message or "审核任务失败",
|
||||
)
|
||||
|
||||
# 已完成:返回 200 + 结果
|
||||
violations = []
|
||||
if task.violations:
|
||||
for v in task.violations:
|
||||
violations.append(Violation(**v))
|
||||
|
||||
soft_warnings = []
|
||||
if task.soft_warnings:
|
||||
for w in task.soft_warnings:
|
||||
soft_warnings.append(SoftRiskWarning(**w))
|
||||
|
||||
return VideoReviewResultResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.COMPLETED,
|
||||
score=task.score or 100,
|
||||
summary=task.summary or "审核完成",
|
||||
violations=violations,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
# ==================== AI 辅助审核方法 ====================
|
||||
|
||||
async def _perform_ai_video_review(
|
||||
task: ReviewTask,
|
||||
ai_client: OpenAICompatibleClient,
|
||||
text_model: str,
|
||||
vision_model: str,
|
||||
audio_model: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
使用 AI 执行视频审核
|
||||
|
||||
流程:
|
||||
1. 下载视频
|
||||
2. ASR 转写
|
||||
3. 提取关键帧
|
||||
4. 视觉分析 (竞品 Logo)
|
||||
5. OCR 字幕
|
||||
6. 生成报告
|
||||
"""
|
||||
violations = []
|
||||
score = 100
|
||||
|
||||
try:
|
||||
# 更新进度: 开始处理
|
||||
task.status = DBTaskStatus.PROCESSING
|
||||
task.progress = 10
|
||||
task.current_step = "下载视频"
|
||||
await db.flush()
|
||||
|
||||
# TODO: 实际实现需要集成视频处理库
|
||||
# 1. 下载视频
|
||||
# video_path = await download_video(task.video_url)
|
||||
|
||||
# 2. ASR 转写
|
||||
task.progress = 30
|
||||
task.current_step = "语音转写"
|
||||
await db.flush()
|
||||
|
||||
# asr_result = await ai_client.audio_transcription(
|
||||
# audio_url=task.video_url, # 需要提取音频
|
||||
# model=audio_model,
|
||||
# )
|
||||
# transcript = asr_result.content
|
||||
|
||||
# 3. 提取关键帧
|
||||
task.progress = 50
|
||||
task.current_step = "提取关键帧"
|
||||
await db.flush()
|
||||
|
||||
# frames = await extract_keyframes(video_path)
|
||||
|
||||
# 4. 视觉分析
|
||||
task.progress = 70
|
||||
task.current_step = "视觉分析"
|
||||
await db.flush()
|
||||
|
||||
# 检测竞品 Logo
|
||||
# if task.competitors:
|
||||
# vision_prompt = f"""
|
||||
# 分析这些视频截图,检测是否包含以下竞品品牌的 Logo 或标识:
|
||||
# 竞品列表: {task.competitors}
|
||||
#
|
||||
# 如果发现竞品,请返回:
|
||||
# 1. 竞品名称
|
||||
# 2. 出现的帧编号
|
||||
# 3. 置信度 (0-1)
|
||||
# """
|
||||
# vision_result = await ai_client.vision_analysis(
|
||||
# image_urls=frames,
|
||||
# prompt=vision_prompt,
|
||||
# model=vision_model,
|
||||
# )
|
||||
|
||||
# 5. 文本综合分析
|
||||
task.progress = 85
|
||||
task.current_step = "综合分析"
|
||||
await db.flush()
|
||||
|
||||
# analysis_prompt = f"""
|
||||
# 作为广告合规审核专家,请分析以下视频脚本内容:
|
||||
#
|
||||
# 脚本内容:
|
||||
# {transcript}
|
||||
#
|
||||
# 请检查:
|
||||
# 1. 是否包含广告法违禁词(最好、第一、最佳等极限词)
|
||||
# 2. 是否包含虚假功效宣称
|
||||
# 3. 品牌信息是否正确
|
||||
#
|
||||
# 返回 JSON 格式:
|
||||
# {{"violations": [...], "score": 0-100, "summary": "..."}}
|
||||
# """
|
||||
# analysis_result = await ai_client.chat_completion(
|
||||
# messages=[{"role": "user", "content": analysis_prompt}],
|
||||
# model=text_model,
|
||||
# )
|
||||
|
||||
# 6. 完成审核
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = score
|
||||
task.summary = "审核完成,未发现违规" if not violations else f"发现 {len(violations)} 处违规"
|
||||
task.violations = [v.model_dump() for v in violations] if violations else []
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": task.summary,
|
||||
"violations": violations,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
task.status = DBTaskStatus.FAILED
|
||||
task.error_message = str(e)
|
||||
await db.flush()
|
||||
raise
|
||||
|
||||
|
||||
# ==================== 后台任务入口 ====================
|
||||
|
||||
async def process_video_review_task(
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
处理视频审核任务(由 Celery 或后台任务调用)
|
||||
"""
|
||||
# 获取任务
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
|
||||
if not ai_client:
|
||||
# 没有配置 AI,使用规则引擎审核
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = 100
|
||||
task.summary = "审核完成(规则引擎)"
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
await db.flush()
|
||||
return
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
models = config.models
|
||||
|
||||
# 执行 AI 审核
|
||||
await _perform_ai_video_review(
|
||||
task=task,
|
||||
ai_client=ai_client,
|
||||
text_model=models.get("text", "gpt-4o"),
|
||||
vision_model=models.get("vision", "gpt-4o"),
|
||||
audio_model=models.get("audio", "whisper-1"),
|
||||
db=db,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Celery 应用配置
|
||||
后台任务队列
|
||||
"""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
"miaosi",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.review"],
|
||||
)
|
||||
|
||||
# 配置
|
||||
celery_app.conf.update(
|
||||
# 任务序列化
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
|
||||
# 时区
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
|
||||
# 任务配置
|
||||
task_track_started=True,
|
||||
task_time_limit=600, # 10 分钟超时
|
||||
task_soft_time_limit=540, # 9 分钟软超时
|
||||
|
||||
# 结果配置
|
||||
result_expires=3600, # 结果保留 1 小时
|
||||
|
||||
# 并发配置
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_concurrency=4,
|
||||
|
||||
# 重试配置
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
|
||||
# 路由配置
|
||||
task_routes={
|
||||
"app.tasks.review.*": {"queue": "review"},
|
||||
},
|
||||
|
||||
# 队列配置
|
||||
task_default_queue="default",
|
||||
|
||||
# 定时任务
|
||||
beat_schedule={
|
||||
# 每小时清理过期临时文件
|
||||
"cleanup-old-files": {
|
||||
"task": "app.tasks.review.cleanup_old_files_task",
|
||||
"schedule": crontab(minute=0), # 每小时整点执行
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""应用配置"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用设置"""
|
||||
# 应用
|
||||
APP_NAME: str = "秒思智能审核平台"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# AI 服务
|
||||
AI_PROVIDER: str = "doubao" # doubao | qwen | deepseek
|
||||
AI_API_KEY: str = ""
|
||||
AI_API_BASE_URL: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置单例"""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 导入所有模型,确保在创建表时被注册
|
||||
from app.models.base import Base
|
||||
from app.models import (
|
||||
Tenant,
|
||||
AIConfig,
|
||||
ReviewTask,
|
||||
ManualTask,
|
||||
ForbiddenWord,
|
||||
WhitelistItem,
|
||||
Competitor,
|
||||
RiskException,
|
||||
)
|
||||
|
||||
# 创建异步引擎
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""获取数据库会话依赖"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""初始化数据库(创建所有表)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db():
|
||||
"""删除所有表(仅用于测试)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
# 导出所有模型,供其他模块使用
|
||||
__all__ = [
|
||||
"Base",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"get_db",
|
||||
"init_db",
|
||||
"drop_db",
|
||||
"Tenant",
|
||||
"AIConfig",
|
||||
"ReviewTask",
|
||||
"ManualTask",
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
"RiskException",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""FastAPI 应用入口"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.api import health, scripts, videos, tasks, rules, ai_config, risk_exceptions, metrics
|
||||
|
||||
# 创建应用
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="AI 营销内容合规审核平台 API",
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if settings.DEBUG else ["https://miaosi.ai"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(scripts.router, prefix="/api/v1")
|
||||
app.include_router(videos.router, prefix="/api/v1")
|
||||
app.include_router(tasks.router, prefix="/api/v1")
|
||||
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.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME}",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs" if settings.DEBUG else "disabled",
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
数据库模型
|
||||
导出所有 ORM 模型
|
||||
"""
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask, ManualTask
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
from app.models.risk_exception import RiskException
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"Tenant",
|
||||
"AIConfig",
|
||||
"ReviewTask",
|
||||
"ManualTask",
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
"RiskException",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
AI 配置模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, Float, Integer, ForeignKey, DateTime
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AIConfig(Base, TimestampMixin):
|
||||
"""AI 服务配置表"""
|
||||
__tablename__ = "ai_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 提供商配置
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 模型配置 (JSON)
|
||||
# {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"}
|
||||
models: Mapped[dict] = mapped_column(JSONType, nullable=False)
|
||||
|
||||
# 参数配置
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=2000, nullable=False)
|
||||
|
||||
# 可用模型缓存 (JSON)
|
||||
available_models: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 测试结果
|
||||
last_test_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
last_test_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 配置状态
|
||||
is_configured: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="ai_config")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AIConfig(tenant_id={self.tenant_id}, provider={self.provider})>"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
数据库模型基类
|
||||
提供公共字段和功能
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""声明基类"""
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""时间戳 Mixin,提供 created_at 和 updated_at 字段"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
审核任务模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Float, Text, ForeignKey, DateTime, Enum as SQLEnum
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Platform(str, enum.Enum):
|
||||
"""投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class ReviewTask(Base, TimestampMixin):
|
||||
"""审核任务表 (AI 自动审核)"""
|
||||
__tablename__ = "review_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 视频信息
|
||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
SQLEnum(Platform, name="platform_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
# 审核状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
current_step: Mapped[str] = mapped_column(String(100), default="等待处理", nullable=False)
|
||||
|
||||
# 审核结果
|
||||
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 违规详情 (JSON 数组)
|
||||
# [{"type": "forbidden_word", "content": "最好", "severity": "high", ...}]
|
||||
violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 软性风控提示 (JSON 数组)
|
||||
soft_warnings: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 审核要求 (JSON)
|
||||
requirements: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 竞品列表
|
||||
competitors: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks")
|
||||
manual_task: Mapped[Optional["ManualTask"]] = relationship(
|
||||
"ManualTask",
|
||||
back_populates="review_task",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ReviewTask(id={self.id}, status={self.status})>"
|
||||
|
||||
|
||||
class ManualTask(Base, TimestampMixin):
|
||||
"""人工审核任务表"""
|
||||
__tablename__ = "manual_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
review_task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("review_tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 视频信息 (冗余存储,即使关联的 review_task 被删除也能查看)
|
||||
video_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
video_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
SQLEnum(Platform, name="platform_enum", create_type=False),
|
||||
nullable=False,
|
||||
)
|
||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
# 脚本信息
|
||||
script_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
script_file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
script_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 任务状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 审批结果
|
||||
approve_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
reject_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
reject_violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 审批人
|
||||
reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
reviewed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="manual_tasks")
|
||||
review_task: Mapped[Optional["ReviewTask"]] = relationship(
|
||||
"ReviewTask",
|
||||
back_populates="manual_task",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ManualTask(id={self.id}, status={self.status})>"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
特例审批模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime, Enum as SQLEnum
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class RiskTargetType(str, enum.Enum):
|
||||
"""特例目标类型"""
|
||||
INFLUENCER = "influencer"
|
||||
ORDER = "order"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class RiskExceptionStatus(str, enum.Enum):
|
||||
"""特例审批状态"""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class RiskException(Base, TimestampMixin):
|
||||
"""特例审批表"""
|
||||
__tablename__ = "risk_exceptions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 申请信息
|
||||
applicant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
apply_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 目标信息
|
||||
target_type: Mapped[RiskTargetType] = mapped_column(
|
||||
SQLEnum(RiskTargetType, name="risk_target_type_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
target_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
risk_rule_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# 状态
|
||||
status: Mapped[RiskExceptionStatus] = mapped_column(
|
||||
SQLEnum(RiskExceptionStatus, name="risk_exception_status_enum"),
|
||||
default=RiskExceptionStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 有效期
|
||||
valid_start_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
valid_end_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 申请原因
|
||||
reason_category: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attachment_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 审批信息
|
||||
current_approver_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# 审批流转日志 (JSON 数组)
|
||||
# [{"approver_id": "...", "action": "approve/reject", "comment": "...", "timestamp": "..."}]
|
||||
approval_chain_log: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
||||
|
||||
# 驳回信息
|
||||
auto_rejected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 最近状态变更时间
|
||||
last_status_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="risk_exceptions")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RiskException(id={self.id}, status={self.status})>"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
规则模型
|
||||
违禁词、白名单、竞品
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class ForbiddenWord(Base, TimestampMixin):
|
||||
"""违禁词表"""
|
||||
__tablename__ = "forbidden_words"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="forbidden_words")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ForbiddenWord(word={self.word}, category={self.category})>"
|
||||
|
||||
|
||||
class WhitelistItem(Base, TimestampMixin):
|
||||
"""白名单表"""
|
||||
__tablename__ = "whitelist_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
term: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="whitelist_items")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhitelistItem(term={self.term}, brand_id={self.brand_id})>"
|
||||
|
||||
|
||||
class Competitor(Base, TimestampMixin):
|
||||
"""竞品表"""
|
||||
__tablename__ = "competitors"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 关键词列表 (JSON 数组)
|
||||
keywords: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="competitors")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
租户模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask, ManualTask
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
from app.models.risk_exception import RiskException
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""租户表"""
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联关系
|
||||
ai_config: Mapped["AIConfig"] = relationship(
|
||||
"AIConfig",
|
||||
back_populates="tenant",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
)
|
||||
review_tasks: Mapped[list["ReviewTask"]] = relationship(
|
||||
"ReviewTask",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
manual_tasks: Mapped[list["ManualTask"]] = relationship(
|
||||
"ManualTask",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
forbidden_words: Mapped[list["ForbiddenWord"]] = relationship(
|
||||
"ForbiddenWord",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
whitelist_items: Mapped[list["WhitelistItem"]] = relationship(
|
||||
"WhitelistItem",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
competitors: Mapped[list["Competitor"]] = relationship(
|
||||
"Competitor",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
risk_exceptions: Mapped[list["RiskException"]] = relationship(
|
||||
"RiskException",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Shared SQLAlchemy column types with cross-database compatibility."""
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# Use JSONB on PostgreSQL, fall back to JSON on other databases (e.g., SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
AI 服务配置相关的 Pydantic 模型
|
||||
"""
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AIProvider(str, Enum):
|
||||
"""支持的 AI 提供商"""
|
||||
# 中转服务
|
||||
ONEAPI = "oneapi"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
# 直连厂商 - 国际
|
||||
ANTHROPIC = "anthropic"
|
||||
OPENAI = "openai"
|
||||
|
||||
# 直连厂商 - 国内
|
||||
DEEPSEEK = "deepseek"
|
||||
QWEN = "qwen"
|
||||
DOUBAO = "doubao"
|
||||
ZHIPU = "zhipu"
|
||||
MOONSHOT = "moonshot"
|
||||
|
||||
|
||||
# 提供商默认 Base URL
|
||||
PROVIDER_DEFAULT_URLS = {
|
||||
AIProvider.ANTHROPIC: "https://api.anthropic.com/v1",
|
||||
AIProvider.OPENAI: "https://api.openai.com/v1",
|
||||
AIProvider.DEEPSEEK: "https://api.deepseek.com/v1",
|
||||
AIProvider.QWEN: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
AIProvider.DOUBAO: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
AIProvider.ZHIPU: "https://open.bigmodel.cn/api/paas/v4",
|
||||
AIProvider.MOONSHOT: "https://api.moonshot.cn/v1",
|
||||
}
|
||||
|
||||
|
||||
class ModelCapability(str, Enum):
|
||||
"""模型能力类型"""
|
||||
TEXT = "text"
|
||||
VISION = "vision"
|
||||
AUDIO = "audio"
|
||||
|
||||
|
||||
# ==================== 请求模型 ====================
|
||||
|
||||
class AIModelsConfig(BaseModel):
|
||||
"""三个模型配置"""
|
||||
text: str = Field(..., description="文字处理模型")
|
||||
vision: str = Field(..., description="视频分析模型")
|
||||
audio: str = Field(..., description="音频解析模型")
|
||||
|
||||
|
||||
class AIParametersConfig(BaseModel):
|
||||
"""参数配置"""
|
||||
temperature: float = Field(default=0.7, ge=0, le=1)
|
||||
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
||||
|
||||
|
||||
class AIConfigUpdate(BaseModel):
|
||||
"""更新 AI 配置请求"""
|
||||
provider: AIProvider
|
||||
base_url: str = Field(..., min_length=1)
|
||||
api_key: str = Field(..., min_length=1)
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig = Field(default_factory=AIParametersConfig)
|
||||
|
||||
|
||||
class GetModelsRequest(BaseModel):
|
||||
"""获取模型列表请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""测试连接请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
models: AIModelsConfig
|
||||
|
||||
|
||||
# ==================== 响应模型 ====================
|
||||
|
||||
class AIConfigResponse(BaseModel):
|
||||
"""AI 配置响应"""
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key_masked: str = Field(..., description="脱敏后的 API Key")
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig
|
||||
available_models: dict[str, list[dict]] = Field(default_factory=dict)
|
||||
is_configured: bool
|
||||
last_test_at: Optional[str] = None
|
||||
last_test_result: Optional[dict] = None
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""模型信息"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ModelsListResponse(BaseModel):
|
||||
"""模型列表响应"""
|
||||
success: bool
|
||||
models: dict[str, list[ModelInfo]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ModelTestResult(BaseModel):
|
||||
"""单个模型测试结果"""
|
||||
success: bool
|
||||
latency_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
model: str
|
||||
|
||||
|
||||
class ConnectionTestResponse(BaseModel):
|
||||
"""测试连接响应"""
|
||||
success: bool
|
||||
results: dict[str, ModelTestResult]
|
||||
message: str
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""API Key 脱敏"""
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
return f"{api_key[:4]}****{api_key[-4:]}"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
审核相关的 Pydantic 模型(API 契约定义)
|
||||
所有测试和实现必须遵循此契约
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ==================== 枚举定义 ====================
|
||||
|
||||
class Platform(str, Enum):
|
||||
"""支持的投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
"""风险等级"""
|
||||
HIGH = "high" # 法律违规(广告法极限词)
|
||||
MEDIUM = "medium" # 平台规则违规
|
||||
LOW = "low" # 品牌规范违规
|
||||
|
||||
|
||||
class ViolationType(str, Enum):
|
||||
"""违规类型"""
|
||||
FORBIDDEN_WORD = "forbidden_word" # 违禁词
|
||||
EFFICACY_CLAIM = "efficacy_claim" # 功效宣称
|
||||
COMPETITOR_LOGO = "competitor_logo" # 竞品露出
|
||||
DURATION_SHORT = "duration_short" # 时长不足
|
||||
MENTION_MISSING = "mention_missing" # 品牌提及不足
|
||||
BRAND_SAFETY = "brand_safety" # 品牌安全风险
|
||||
|
||||
|
||||
class ViolationSource(str, Enum):
|
||||
"""违规来源"""
|
||||
TEXT = "text" # 文本/脚本
|
||||
SPEECH = "speech" # 语音(ASR)
|
||||
SUBTITLE = "subtitle" # 字幕(OCR)
|
||||
VISUAL = "visual" # 画面(CV)
|
||||
|
||||
|
||||
class SoftRiskAction(str, Enum):
|
||||
"""软性风控动作"""
|
||||
CONFIRM = "confirm" # 需要二次确认
|
||||
NOTE = "note" # 需要填写备注
|
||||
|
||||
|
||||
class SoftRiskWarning(BaseModel):
|
||||
"""软性风控提示(Warn-only)"""
|
||||
code: str = Field(..., description="提示类型代码")
|
||||
message: str = Field(..., description="提示内容")
|
||||
action_required: SoftRiskAction = Field(..., description="要求动作")
|
||||
blocking: bool = Field(default=False, description="是否阻断(默认不阻断)")
|
||||
context: Optional[dict] = Field(None, description="附加上下文")
|
||||
|
||||
|
||||
class SoftRiskContext(BaseModel):
|
||||
"""软性风控输入上下文"""
|
||||
violation_rate: Optional[float] = Field(None, ge=0, le=1, description="违规率")
|
||||
violation_threshold: Optional[float] = Field(None, ge=0, le=1, description="违规率阈值")
|
||||
asr_confidence: Optional[float] = Field(None, ge=0, le=1, description="ASR 置信度")
|
||||
ocr_confidence: Optional[float] = Field(None, ge=0, le=1, description="OCR 置信度")
|
||||
has_history_violation: Optional[bool] = Field(None, description="是否有历史类似违规")
|
||||
|
||||
|
||||
# ==================== 通用模型 ====================
|
||||
|
||||
class Position(BaseModel):
|
||||
"""文本位置"""
|
||||
start: int = Field(..., description="起始位置")
|
||||
end: int = Field(..., description="结束位置")
|
||||
|
||||
|
||||
class Violation(BaseModel):
|
||||
"""违规项(统一结构)"""
|
||||
type: ViolationType = Field(..., description="违规类型")
|
||||
content: str = Field(..., description="违规内容")
|
||||
severity: RiskLevel = Field(..., description="严重程度")
|
||||
suggestion: str = Field(..., description="修改建议")
|
||||
|
||||
# 文本审核字段
|
||||
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
||||
|
||||
# 视频审核字段
|
||||
timestamp: Optional[float] = Field(None, description="开始时间戳(秒)")
|
||||
timestamp_end: Optional[float] = Field(None, description="结束时间戳(秒)")
|
||||
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
||||
|
||||
|
||||
# ==================== 脚本预审 ====================
|
||||
|
||||
class ScriptReviewRequest(BaseModel):
|
||||
"""脚本预审请求"""
|
||||
content: str = Field(..., min_length=1, description="脚本内容")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
required_points: Optional[list[str]] = Field(None, description="必要卖点列表")
|
||||
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
||||
|
||||
|
||||
class ScriptReviewResponse(BaseModel):
|
||||
"""
|
||||
脚本预审响应
|
||||
|
||||
结构:
|
||||
- score: 合规分数 0-100
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 suggestion
|
||||
- missing_points: 遗漏的卖点(可选)
|
||||
"""
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
|
||||
|
||||
# ==================== 视频审核 ====================
|
||||
|
||||
class VideoReviewRequest(BaseModel):
|
||||
"""视频审核请求"""
|
||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
competitors: Optional[list[str]] = Field(None, description="竞品列表")
|
||||
requirements: Optional[dict] = Field(None, description="审核要求(时长、频次等)")
|
||||
|
||||
|
||||
class VideoReviewSubmitResponse(BaseModel):
|
||||
"""视频审核提交响应(202 Accepted)"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.PENDING, description="任务状态")
|
||||
|
||||
|
||||
class VideoReviewProgressResponse(BaseModel):
|
||||
"""视频审核进度响应"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(..., description="任务状态")
|
||||
progress: int = Field(..., ge=0, le=100, description="进度百分比")
|
||||
current_step: str = Field(..., description="当前处理步骤")
|
||||
|
||||
|
||||
class VideoReviewResultResponse(BaseModel):
|
||||
"""
|
||||
视频审核结果响应(200 OK)
|
||||
|
||||
结构与脚本审核一致:
|
||||
- score: 合规分数
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 timestamp 和 suggestion
|
||||
"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.COMPLETED, description="任务状态")
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
|
||||
|
||||
# ==================== 一致性指标 ====================
|
||||
|
||||
class ConsistencyWindow(str, Enum):
|
||||
"""一致性指标计算周期"""
|
||||
ROLLING_30D = "rolling_30d"
|
||||
SNAPSHOT_WEEK = "snapshot_week"
|
||||
SNAPSHOT_MONTH = "snapshot_month"
|
||||
|
||||
|
||||
class RuleConsistencyMetric(BaseModel):
|
||||
"""按规则类型的指标"""
|
||||
rule_type: ViolationType = Field(..., description="规则类型")
|
||||
total_reviews: int = Field(..., ge=0, description="总审核数")
|
||||
violation_count: int = Field(..., ge=0, description="违规数")
|
||||
violation_rate: float = Field(..., ge=0, le=1, description="违规率(0-1)")
|
||||
|
||||
|
||||
class ConsistencyMetricsResponse(BaseModel):
|
||||
"""一致性指标响应"""
|
||||
influencer_id: str = Field(..., description="达人 ID")
|
||||
window: ConsistencyWindow = Field(..., description="计算周期")
|
||||
period_start: datetime = Field(..., description="周期起始时间")
|
||||
period_end: datetime = Field(..., description="周期结束时间")
|
||||
metrics: list[RuleConsistencyMetric] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ==================== 特例审批(风控豁免) ====================
|
||||
|
||||
class RiskTargetType(str, Enum):
|
||||
"""特例目标类型"""
|
||||
INFLUENCER = "influencer"
|
||||
ORDER = "order"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class RiskExceptionStatus(str, Enum):
|
||||
"""特例审批状态"""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class RiskExceptionCreateRequest(BaseModel):
|
||||
"""创建特例请求"""
|
||||
applicant_id: str = Field(..., description="申请人")
|
||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
||||
target_id: str = Field(..., description="目标 ID")
|
||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
||||
reason_category: str = Field(..., description="原因分类")
|
||||
justification: str = Field(..., min_length=1, description="详细理由")
|
||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
||||
current_approver_id: str = Field(..., description="当前审批人")
|
||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
||||
|
||||
|
||||
class RiskExceptionRecord(BaseModel):
|
||||
"""特例记录"""
|
||||
record_id: str = Field(..., description="记录 ID")
|
||||
applicant_id: str = Field(..., description="申请人")
|
||||
apply_time: datetime = Field(..., description="申请时间")
|
||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
||||
target_id: str = Field(..., description="目标 ID")
|
||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
||||
status: RiskExceptionStatus = Field(..., description="状态")
|
||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
||||
reason_category: str = Field(..., description="原因分类")
|
||||
justification: str = Field(..., description="详细理由")
|
||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
||||
current_approver_id: Optional[str] = Field(None, description="当前审批人")
|
||||
approval_chain_log: list[dict] = Field(default_factory=list, description="审批流转日志")
|
||||
auto_rejected: bool = Field(default=False, description="是否超时自动拒绝")
|
||||
rejection_reason: Optional[str] = Field(None, description="驳回原因")
|
||||
last_status_at: Optional[datetime] = Field(None, description="最近状态变更时间")
|
||||
|
||||
|
||||
class RiskExceptionDecisionRequest(BaseModel):
|
||||
"""特例审批决策请求"""
|
||||
approver_id: str = Field(..., description="审批人")
|
||||
comment: Optional[str] = Field(None, description="审批备注")
|
||||
|
||||
|
||||
# ==================== 审核任务 ====================
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""创建任务请求"""
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
video_url: Optional[HttpUrl] = Field(None, description="视频 URL")
|
||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
||||
|
||||
|
||||
class TaskScriptUploadRequest(BaseModel):
|
||||
"""上传脚本请求"""
|
||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
||||
|
||||
|
||||
class TaskVideoUploadRequest(BaseModel):
|
||||
"""上传视频请求"""
|
||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""任务响应"""
|
||||
task_id: str = Field(..., description="任务 ID")
|
||||
video_url: Optional[str] = Field(None, description="视频 URL")
|
||||
script_content: Optional[str] = Field(None, description="脚本内容")
|
||||
script_file_url: Optional[str] = Field(None, description="脚本文档 URL")
|
||||
has_script: bool = Field(..., description="是否已上传脚本")
|
||||
has_video: bool = Field(..., description="是否已上传视频")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
status: TaskStatus = Field(..., description="任务状态")
|
||||
created_at: str = Field(..., description="创建时间")
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
"""任务列表响应"""
|
||||
items: list[TaskResponse] = Field(default_factory=list)
|
||||
total: int = Field(..., description="总数")
|
||||
page: int = Field(..., description="当前页")
|
||||
page_size: int = Field(..., description="每页数量")
|
||||
|
||||
|
||||
class TaskApproveRequest(BaseModel):
|
||||
"""通过任务请求"""
|
||||
comment: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class TaskRejectRequest(BaseModel):
|
||||
"""驳回任务请求"""
|
||||
reason: str = Field(..., min_length=1, description="驳回原因")
|
||||
violations: list[str] = Field(default_factory=list, description="违规类型列表")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""服务层模块"""
|
||||
from typing import Optional, Any
|
||||
|
||||
_openai_import_error: Optional[Exception] = None
|
||||
|
||||
try:
|
||||
from app.services.ai_client import OpenAICompatibleClient, AIResponse, ConnectionTestResult
|
||||
from app.services.ai_service import AIServiceFactory, get_ai_client_for_tenant
|
||||
except ModuleNotFoundError as exc: # openai 依赖缺失时允许非 AI 路径正常导入
|
||||
_openai_import_error = exc
|
||||
OpenAICompatibleClient = None
|
||||
AIResponse = None
|
||||
ConnectionTestResult = None
|
||||
AIServiceFactory = None
|
||||
|
||||
def get_ai_client_for_tenant(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise ModuleNotFoundError(
|
||||
"Optional dependency 'openai' is required for AI client usage."
|
||||
) from _openai_import_error
|
||||
|
||||
# 视频处理服务(无外部依赖)
|
||||
from app.services.video_download import VideoDownloadService, DownloadResult, get_download_service
|
||||
from app.services.keyframe import KeyFrameExtractor, KeyFrame, ExtractionResult, get_keyframe_extractor
|
||||
from app.services.asr import ASRService, VideoASRService, TranscriptionResult
|
||||
from app.services.vision import VisionAnalysisService, CompetitorLogoDetector, VideoOCRService
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
__all__ = [
|
||||
# AI 客户端
|
||||
"OpenAICompatibleClient",
|
||||
"AIResponse",
|
||||
"ConnectionTestResult",
|
||||
"AIServiceFactory",
|
||||
"get_ai_client_for_tenant",
|
||||
# 视频下载
|
||||
"VideoDownloadService",
|
||||
"DownloadResult",
|
||||
"get_download_service",
|
||||
# 关键帧提取
|
||||
"KeyFrameExtractor",
|
||||
"KeyFrame",
|
||||
"ExtractionResult",
|
||||
"get_keyframe_extractor",
|
||||
# ASR
|
||||
"ASRService",
|
||||
"VideoASRService",
|
||||
"TranscriptionResult",
|
||||
# 视觉分析
|
||||
"VisionAnalysisService",
|
||||
"CompetitorLogoDetector",
|
||||
"VideoOCRService",
|
||||
# 视频审核
|
||||
"VideoReviewService",
|
||||
]
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
OpenAI 兼容 AI 客户端
|
||||
支持多种 AI 提供商的统一接口
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.schemas.ai_config import AIProvider, ModelCapability
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIResponse:
|
||||
"""AI 响应"""
|
||||
content: str
|
||||
model: str
|
||||
usage: dict
|
||||
finish_reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionTestResult:
|
||||
"""连接测试结果"""
|
||||
success: bool
|
||||
latency_ms: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class OpenAICompatibleClient:
|
||||
"""
|
||||
OpenAI 兼容 API 客户端
|
||||
|
||||
支持:
|
||||
- OpenAI
|
||||
- Azure OpenAI
|
||||
- Anthropic (通过 OpenAI 兼容层)
|
||||
- DeepSeek
|
||||
- Qwen (通义千问)
|
||||
- Doubao (豆包)
|
||||
- 各种中转服务 (OneAPI, OpenRouter)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider: str = "openai",
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.provider = provider
|
||||
self.timeout = timeout
|
||||
|
||||
# 创建 OpenAI 客户端
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
**kwargs,
|
||||
) -> AIResponse:
|
||||
"""
|
||||
聊天补全
|
||||
|
||||
Args:
|
||||
messages: 消息列表 [{"role": "user", "content": "..."}]
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
|
||||
Returns:
|
||||
AIResponse 包含生成的内容
|
||||
"""
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
return AIResponse(
|
||||
content=choice.message.content or "",
|
||||
model=response.model,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
|
||||
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
|
||||
"total_tokens": response.usage.total_tokens if response.usage else 0,
|
||||
},
|
||||
finish_reason=choice.finish_reason or "stop",
|
||||
)
|
||||
|
||||
async def vision_analysis(
|
||||
self,
|
||||
image_urls: list[str],
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 2000,
|
||||
) -> AIResponse:
|
||||
"""
|
||||
视觉分析(图像理解)
|
||||
|
||||
Args:
|
||||
image_urls: 图像 URL 列表
|
||||
prompt: 分析提示
|
||||
model: 视觉模型名称
|
||||
|
||||
Returns:
|
||||
AIResponse 包含分析结果
|
||||
"""
|
||||
# 构建多模态消息
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
|
||||
for url in image_urls:
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": url},
|
||||
})
|
||||
|
||||
messages = [{"role": "user", "content": content}]
|
||||
|
||||
return await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
async def audio_transcription(
|
||||
self,
|
||||
audio_url: str,
|
||||
model: str = "whisper-1",
|
||||
language: str = "zh",
|
||||
) -> AIResponse:
|
||||
"""
|
||||
音频转写 (ASR)
|
||||
|
||||
Args:
|
||||
audio_url: 音频文件 URL
|
||||
model: 转写模型
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
AIResponse 包含转写文本
|
||||
"""
|
||||
# 下载音频文件
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(audio_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
audio_data = response.content
|
||||
|
||||
# 调用 Whisper API
|
||||
transcription = await self.client.audio.transcriptions.create(
|
||||
model=model,
|
||||
file=("audio.mp3", audio_data, "audio/mpeg"),
|
||||
language=language,
|
||||
)
|
||||
|
||||
return AIResponse(
|
||||
content=transcription.text,
|
||||
model=model,
|
||||
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
async def test_connection(
|
||||
self,
|
||||
model: str,
|
||||
capability: ModelCapability = ModelCapability.TEXT,
|
||||
) -> ConnectionTestResult:
|
||||
"""
|
||||
测试模型连接
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
capability: 模型能力类型
|
||||
|
||||
Returns:
|
||||
ConnectionTestResult 包含测试结果
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if capability == ModelCapability.AUDIO:
|
||||
# 音频模型无法简单测试,只验证 API 可达
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(success=True, latency_ms=latency_ms)
|
||||
|
||||
elif capability == ModelCapability.VISION:
|
||||
# 视觉模型测试:发送简单的文本请求
|
||||
response = await self.chat_completion(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
model=model,
|
||||
max_tokens=5,
|
||||
)
|
||||
|
||||
else:
|
||||
# 文本模型测试
|
||||
response = await self.chat_completion(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
model=model,
|
||||
max_tokens=5,
|
||||
)
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(success=True, latency_ms=latency_ms)
|
||||
|
||||
except Exception as e:
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
return ConnectionTestResult(
|
||||
success=False,
|
||||
latency_ms=latency_ms,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def list_models(self) -> dict[str, list[dict]]:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
Returns:
|
||||
按能力分类的模型列表
|
||||
{"text": [...], "vision": [...], "audio": [...]}
|
||||
"""
|
||||
try:
|
||||
models = await self.client.models.list()
|
||||
|
||||
# 已知模型能力映射
|
||||
known_capabilities = {
|
||||
# OpenAI
|
||||
"gpt-4o": ["text", "vision"],
|
||||
"gpt-4o-mini": ["text", "vision"],
|
||||
"gpt-4-turbo": ["text", "vision"],
|
||||
"gpt-4": ["text"],
|
||||
"gpt-3.5-turbo": ["text"],
|
||||
"whisper-1": ["audio"],
|
||||
|
||||
# Claude (通过兼容层)
|
||||
"claude-3-opus": ["text", "vision"],
|
||||
"claude-3-sonnet": ["text", "vision"],
|
||||
"claude-3-haiku": ["text", "vision"],
|
||||
|
||||
# DeepSeek
|
||||
"deepseek-chat": ["text"],
|
||||
"deepseek-coder": ["text"],
|
||||
|
||||
# Qwen
|
||||
"qwen-turbo": ["text"],
|
||||
"qwen-plus": ["text"],
|
||||
"qwen-max": ["text"],
|
||||
"qwen-vl-plus": ["vision"],
|
||||
"qwen-vl-max": ["vision"],
|
||||
|
||||
# Doubao
|
||||
"doubao-pro": ["text"],
|
||||
"doubao-lite": ["text"],
|
||||
}
|
||||
|
||||
result: dict[str, list[dict]] = {
|
||||
"text": [],
|
||||
"vision": [],
|
||||
"audio": [],
|
||||
}
|
||||
|
||||
for model in models.data:
|
||||
model_id = model.id
|
||||
capabilities = known_capabilities.get(model_id, ["text"])
|
||||
|
||||
for cap in capabilities:
|
||||
if cap in result:
|
||||
result[cap].append({
|
||||
"id": model_id,
|
||||
"name": model_id.replace("-", " ").title(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
# 如果无法获取模型列表,返回预设列表
|
||||
return {
|
||||
"text": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "gpt-4o-mini", "name": "GPT-4o Mini"},
|
||||
{"id": "deepseek-chat", "name": "DeepSeek Chat"},
|
||||
],
|
||||
"vision": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "qwen-vl-max", "name": "Qwen VL Max"},
|
||||
],
|
||||
"audio": [
|
||||
{"id": "whisper-1", "name": "Whisper"},
|
||||
],
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception:
|
||||
# 关闭失败不应影响主流程
|
||||
pass
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def create_ai_client(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
provider: str = "openai",
|
||||
) -> OpenAICompatibleClient:
|
||||
"""创建 AI 客户端"""
|
||||
return OpenAICompatibleClient(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
AI 服务工厂
|
||||
根据租户配置创建和管理 AI 客户端
|
||||
"""
|
||||
from typing import Optional
|
||||
from cachetools import TTLCache
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.utils.crypto import decrypt_api_key
|
||||
|
||||
|
||||
class AIServiceFactory:
|
||||
"""
|
||||
AI 服务工厂
|
||||
|
||||
根据租户的 AI 配置创建对应的 AI 客户端
|
||||
使用 TTL 缓存避免频繁创建客户端
|
||||
"""
|
||||
|
||||
# 客户端缓存,TTL 10 分钟
|
||||
_cache: TTLCache = TTLCache(maxsize=100, ttl=600)
|
||||
|
||||
@classmethod
|
||||
async def get_client(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[OpenAICompatibleClient]:
|
||||
"""
|
||||
获取租户的 AI 客户端
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
AI 客户端实例,未配置返回 None
|
||||
"""
|
||||
# 检查缓存
|
||||
cache_key = f"ai_client:{tenant_id}"
|
||||
if cache_key in cls._cache:
|
||||
return cls._cache[cache_key]
|
||||
|
||||
# 从数据库获取配置
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# 解密 API Key
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
# 创建客户端
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=config.base_url,
|
||||
api_key=api_key,
|
||||
provider=config.provider,
|
||||
)
|
||||
|
||||
# 缓存客户端
|
||||
cls._cache[cache_key] = client
|
||||
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls, tenant_id: str) -> None:
|
||||
"""
|
||||
使缓存失效
|
||||
|
||||
当租户更新 AI 配置时调用
|
||||
"""
|
||||
cache_key = f"ai_client:{tenant_id}"
|
||||
if cache_key in cls._cache:
|
||||
del cls._cache[cache_key]
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls) -> None:
|
||||
"""清空所有缓存"""
|
||||
cls._cache.clear()
|
||||
|
||||
@classmethod
|
||||
async def get_config(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[AIConfig]:
|
||||
"""
|
||||
获取租户的 AI 配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
AI 配置模型,未配置返回 None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def create_or_update_config(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
api_key_encrypted: str,
|
||||
models: dict,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
db: AsyncSession,
|
||||
) -> AIConfig:
|
||||
"""
|
||||
创建或更新 AI 配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
provider: 提供商
|
||||
base_url: API 地址
|
||||
api_key_encrypted: 加密的 API Key
|
||||
models: 模型配置
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的配置
|
||||
"""
|
||||
# 查找现有配置
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
# 更新现有配置
|
||||
config.provider = provider
|
||||
config.base_url = base_url
|
||||
config.api_key_encrypted = api_key_encrypted
|
||||
config.models = models
|
||||
config.temperature = temperature
|
||||
config.max_tokens = max_tokens
|
||||
config.is_configured = True
|
||||
else:
|
||||
# 创建新配置
|
||||
config = AIConfig(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=models,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
is_configured=True,
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# 使缓存失效
|
||||
cls.invalidate_cache(tenant_id)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def get_ai_client_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[OpenAICompatibleClient]:
|
||||
"""获取租户的 AI 客户端"""
|
||||
return await AIServiceFactory.get_client(tenant_id, db)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
ASR 语音转写服务
|
||||
集成 Whisper API 实现音频转写
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
"""转写片段"""
|
||||
text: str
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptionResult:
|
||||
"""转写结果"""
|
||||
success: bool
|
||||
text: str = "" # 完整文本
|
||||
segments: list[TranscriptSegment] = field(default_factory=list)
|
||||
language: str = "zh"
|
||||
duration: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ASRService:
|
||||
"""ASR 语音转写服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "whisper-1",
|
||||
timeout: float = 300.0,
|
||||
):
|
||||
"""
|
||||
初始化 ASR 服务
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
base_url: API 基础 URL
|
||||
model: 模型名称
|
||||
timeout: 请求超时(秒)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
|
||||
async def transcribe_file(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: str = "zh",
|
||||
response_format: str = "verbose_json",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写音频文件
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径
|
||||
language: 语言代码
|
||||
response_format: 响应格式
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"文件不存在: {audio_path}",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout)
|
||||
) as client:
|
||||
with open(audio_path, "rb") as f:
|
||||
files = {"file": (os.path.basename(audio_path), f, "audio/mpeg")}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"language": language,
|
||||
"response_format": response_format,
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{self.base_url}/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"API 错误 {response.status_code}: {response.text[:200]}",
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
return self._parse_response(result, language)
|
||||
|
||||
except Exception as e:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def transcribe_url(
|
||||
self,
|
||||
audio_url: str,
|
||||
language: str = "zh",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写远程音频
|
||||
|
||||
Args:
|
||||
audio_url: 音频 URL
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
# 下载音频到临时文件
|
||||
temp_path = None
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(audio_url)
|
||||
if response.status_code != 200:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=f"下载音频失败: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# 写入临时文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".mp3",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(response.content)
|
||||
temp_path = f.name
|
||||
|
||||
# 转写
|
||||
result = await self.transcribe_file(temp_path, language)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _parse_response(
|
||||
self,
|
||||
response: dict,
|
||||
language: str,
|
||||
) -> TranscriptionResult:
|
||||
"""解析 API 响应"""
|
||||
text = response.get("text", "")
|
||||
duration = response.get("duration", 0.0)
|
||||
|
||||
segments = []
|
||||
for seg in response.get("segments", []):
|
||||
segments.append(TranscriptSegment(
|
||||
text=seg.get("text", "").strip(),
|
||||
start=seg.get("start", 0.0),
|
||||
end=seg.get("end", 0.0),
|
||||
confidence=seg.get("confidence", 1.0) if "confidence" in seg else 1.0,
|
||||
))
|
||||
|
||||
# 如果没有分段信息,创建单个分段
|
||||
if not segments and text:
|
||||
segments = [TranscriptSegment(
|
||||
text=text,
|
||||
start=0.0,
|
||||
end=duration,
|
||||
)]
|
||||
|
||||
return TranscriptionResult(
|
||||
success=True,
|
||||
text=text,
|
||||
segments=segments,
|
||||
language=language,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
class AudioExtractor:
|
||||
"""从视频中提取音频"""
|
||||
|
||||
def __init__(self, ffmpeg_path: str = "ffmpeg"):
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
|
||||
async def extract_audio(
|
||||
self,
|
||||
video_path: str,
|
||||
output_path: Optional[str] = None,
|
||||
format: str = "mp3",
|
||||
sample_rate: int = 16000,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
从视频中提取音频
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出路径,默认生成临时文件
|
||||
format: 输出格式
|
||||
sample_rate: 采样率
|
||||
|
||||
Returns:
|
||||
音频文件路径,失败返回 None
|
||||
"""
|
||||
import shutil
|
||||
|
||||
if not shutil.which(self.ffmpeg_path):
|
||||
return None
|
||||
|
||||
if output_path is None:
|
||||
output_path = tempfile.mktemp(suffix=f".{format}")
|
||||
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vn", # 不要视频
|
||||
"-acodec", "libmp3lame" if format == "mp3" else "pcm_s16le",
|
||||
"-ar", str(sample_rate),
|
||||
"-ac", "1", # 单声道
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
return output_path
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class VideoASRService:
|
||||
"""视频 ASR 服务(组合音频提取和转写)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "whisper-1",
|
||||
):
|
||||
self.asr = ASRService(api_key, base_url, model)
|
||||
self.audio_extractor = AudioExtractor()
|
||||
|
||||
async def transcribe_video(
|
||||
self,
|
||||
video_path: str,
|
||||
language: str = "zh",
|
||||
) -> TranscriptionResult:
|
||||
"""
|
||||
转写视频中的语音
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
language: 语言代码
|
||||
|
||||
Returns:
|
||||
TranscriptionResult: 转写结果
|
||||
"""
|
||||
# 提取音频
|
||||
audio_path = await self.audio_extractor.extract_audio(video_path)
|
||||
if not audio_path:
|
||||
return TranscriptionResult(
|
||||
success=False,
|
||||
error="音频提取失败,请确保 FFmpeg 已安装",
|
||||
)
|
||||
|
||||
try:
|
||||
# 转写
|
||||
result = await self.asr.transcribe_file(audio_path, language)
|
||||
return result
|
||||
finally:
|
||||
# 清理临时音频
|
||||
if os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
健康检查服务
|
||||
提供依赖注入接口,便于测试 mock
|
||||
"""
|
||||
from typing import Protocol, Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
|
||||
class HealthChecker(Protocol):
|
||||
"""健康检查协议(用于类型提示)"""
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
"""检查数据库连接"""
|
||||
...
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
"""检查 Redis 连接"""
|
||||
...
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
"""检查所有依赖"""
|
||||
...
|
||||
|
||||
|
||||
class DefaultHealthChecker:
|
||||
"""
|
||||
默认健康检查实现
|
||||
生产环境使用,检查真实依赖
|
||||
"""
|
||||
|
||||
# 默认连接超时(秒)
|
||||
DEFAULT_CONNECT_TIMEOUT = 5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_engine: Optional[AsyncEngine] = None,
|
||||
redis_url: Optional[str] = None,
|
||||
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||
):
|
||||
self._db_engine = db_engine
|
||||
self._redis_url = redis_url
|
||||
self._connect_timeout = connect_timeout
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
"""
|
||||
检查数据库连接
|
||||
|
||||
Returns:
|
||||
bool: 数据库是否可用
|
||||
"""
|
||||
if self._db_engine is None:
|
||||
# 未配置数据库引擎,尝试从全局获取
|
||||
try:
|
||||
from app.database import engine
|
||||
self._db_engine = engine
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with self._db_engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
"""
|
||||
检查 Redis 连接
|
||||
|
||||
Returns:
|
||||
bool: Redis 是否可用
|
||||
"""
|
||||
if self._redis_url is None:
|
||||
# 未配置 Redis URL,尝试从配置获取
|
||||
try:
|
||||
from app.config import settings
|
||||
self._redis_url = settings.REDIS_URL
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
client = aioredis.from_url(
|
||||
self._redis_url,
|
||||
socket_connect_timeout=self._connect_timeout
|
||||
)
|
||||
try:
|
||||
await client.ping()
|
||||
return True
|
||||
finally:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
"""检查所有依赖"""
|
||||
return {
|
||||
"database": await self.check_database(),
|
||||
"redis": await self.check_redis(),
|
||||
}
|
||||
|
||||
|
||||
class MockHealthChecker:
|
||||
"""
|
||||
Mock 健康检查实现
|
||||
测试环境使用,可配置返回值
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database_healthy: bool = True,
|
||||
redis_healthy: bool = True,
|
||||
):
|
||||
self._database_healthy = database_healthy
|
||||
self._redis_healthy = redis_healthy
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
return self._database_healthy
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
return self._redis_healthy
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
return {
|
||||
"database": self._database_healthy,
|
||||
"redis": self._redis_healthy,
|
||||
}
|
||||
|
||||
|
||||
def get_health_checker() -> HealthChecker:
|
||||
"""
|
||||
获取健康检查器依赖
|
||||
|
||||
生产环境返回 DefaultHealthChecker(检查真实依赖)
|
||||
测试环境通过 app.dependency_overrides 替换
|
||||
"""
|
||||
return DefaultHealthChecker()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
关键帧提取服务
|
||||
使用 FFmpeg 从视频中提取关键帧用于视觉分析
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyFrame:
|
||||
"""关键帧数据"""
|
||||
timestamp: float # 时间戳(秒)
|
||||
file_path: str # 帧图片路径
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
def to_base64(self) -> str:
|
||||
"""将帧图片转为 base64"""
|
||||
with open(self.file_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
def to_data_url(self) -> str:
|
||||
"""将帧图片转为 data URL"""
|
||||
return f"data:image/jpeg;base64,{self.to_base64()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
"""提取结果"""
|
||||
success: bool
|
||||
frames: list[KeyFrame] = field(default_factory=list)
|
||||
video_duration: float = 0.0
|
||||
error: Optional[str] = None
|
||||
output_dir: Optional[str] = None
|
||||
|
||||
|
||||
class KeyFrameExtractor:
|
||||
"""关键帧提取器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
ffprobe_path: str = "ffprobe",
|
||||
output_format: str = "jpg",
|
||||
quality: int = 2, # 1-31, 越小质量越高
|
||||
):
|
||||
"""
|
||||
初始化提取器
|
||||
|
||||
Args:
|
||||
ffmpeg_path: ffmpeg 可执行文件路径
|
||||
ffprobe_path: ffprobe 可执行文件路径
|
||||
output_format: 输出格式 (jpg/png)
|
||||
quality: JPEG 质量 (1-31)
|
||||
"""
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.ffprobe_path = ffprobe_path
|
||||
self.output_format = output_format
|
||||
self.quality = quality
|
||||
|
||||
def _check_ffmpeg(self) -> bool:
|
||||
"""检查 FFmpeg 是否可用"""
|
||||
return shutil.which(self.ffmpeg_path) is not None
|
||||
|
||||
async def get_video_info(self, video_path: str) -> dict:
|
||||
"""
|
||||
获取视频信息
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
Returns:
|
||||
视频信息字典
|
||||
"""
|
||||
cmd = [
|
||||
self.ffprobe_path,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
video_path,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
|
||||
import json
|
||||
info = json.loads(stdout.decode())
|
||||
|
||||
# 提取关键信息
|
||||
duration = float(info.get("format", {}).get("duration", 0))
|
||||
video_stream = next(
|
||||
(s for s in info.get("streams", []) if s.get("codec_type") == "video"),
|
||||
{}
|
||||
)
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"width": video_stream.get("width", 0),
|
||||
"height": video_stream.get("height", 0),
|
||||
"fps": eval(video_stream.get("r_frame_rate", "0/1")) if "/" in video_stream.get("r_frame_rate", "0") else 0,
|
||||
"codec": video_stream.get("codec_name", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "duration": 0}
|
||||
|
||||
async def extract_at_intervals(
|
||||
self,
|
||||
video_path: str,
|
||||
interval_seconds: float = 1.0,
|
||||
max_frames: int = 60,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> ExtractionResult:
|
||||
"""
|
||||
按时间间隔提取帧
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
interval_seconds: 提取间隔(秒)
|
||||
max_frames: 最大帧数
|
||||
output_dir: 输出目录,默认创建临时目录
|
||||
|
||||
Returns:
|
||||
ExtractionResult: 提取结果
|
||||
"""
|
||||
if not self._check_ffmpeg():
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="FFmpeg 未安装或不在 PATH 中",
|
||||
)
|
||||
|
||||
# 获取视频信息
|
||||
video_info = await self.get_video_info(video_path)
|
||||
duration = video_info.get("duration", 0)
|
||||
|
||||
if duration <= 0:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="无法获取视频时长",
|
||||
)
|
||||
|
||||
# 创建输出目录
|
||||
if output_dir is None:
|
||||
output_dir = tempfile.mkdtemp(prefix="keyframes_")
|
||||
else:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 计算实际帧数
|
||||
frame_count = min(int(duration / interval_seconds), max_frames)
|
||||
if frame_count <= 0:
|
||||
frame_count = 1
|
||||
|
||||
# 使用 FFmpeg 提取帧
|
||||
output_pattern = os.path.join(output_dir, f"frame_%04d.{self.output_format}")
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vf", f"fps=1/{interval_seconds}",
|
||||
"-frames:v", str(frame_count),
|
||||
"-q:v", str(self.quality),
|
||||
"-y",
|
||||
output_pattern,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=f"FFmpeg 错误: {stderr.decode()[:200]}",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
# 收集提取的帧
|
||||
frames = []
|
||||
for i in range(1, frame_count + 1):
|
||||
frame_path = os.path.join(output_dir, f"frame_{i:04d}.{self.output_format}")
|
||||
if os.path.exists(frame_path):
|
||||
timestamp = (i - 1) * interval_seconds
|
||||
frames.append(KeyFrame(
|
||||
timestamp=timestamp,
|
||||
file_path=frame_path,
|
||||
width=video_info.get("width", 0),
|
||||
height=video_info.get("height", 0),
|
||||
))
|
||||
|
||||
return ExtractionResult(
|
||||
success=True,
|
||||
frames=frames,
|
||||
video_duration=duration,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
async def extract_scene_changes(
|
||||
self,
|
||||
video_path: str,
|
||||
threshold: float = 0.3,
|
||||
max_frames: int = 30,
|
||||
output_dir: Optional[str] = None,
|
||||
) -> ExtractionResult:
|
||||
"""
|
||||
基于场景变化提取关键帧
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
threshold: 场景变化阈值 (0-1)
|
||||
max_frames: 最大帧数
|
||||
output_dir: 输出目录
|
||||
|
||||
Returns:
|
||||
ExtractionResult: 提取结果
|
||||
"""
|
||||
if not self._check_ffmpeg():
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error="FFmpeg 未安装或不在 PATH 中",
|
||||
)
|
||||
|
||||
video_info = await self.get_video_info(video_path)
|
||||
duration = video_info.get("duration", 0)
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = tempfile.mkdtemp(prefix="keyframes_")
|
||||
else:
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_pattern = os.path.join(output_dir, f"scene_%04d.{self.output_format}")
|
||||
|
||||
# 使用场景检测滤镜
|
||||
cmd = [
|
||||
self.ffmpeg_path,
|
||||
"-i", video_path,
|
||||
"-vf", f"select='gt(scene,{threshold})',showinfo",
|
||||
"-vsync", "vfr",
|
||||
"-frames:v", str(max_frames),
|
||||
"-q:v", str(self.quality),
|
||||
"-y",
|
||||
output_pattern,
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await process.communicate()
|
||||
|
||||
# 解析时间戳
|
||||
timestamps = []
|
||||
for line in stderr.decode().split("\n"):
|
||||
if "pts_time:" in line:
|
||||
try:
|
||||
pts_part = line.split("pts_time:")[1].split()[0]
|
||||
timestamps.append(float(pts_part))
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# 收集帧
|
||||
frames = []
|
||||
for i, ts in enumerate(timestamps[:max_frames], 1):
|
||||
frame_path = os.path.join(output_dir, f"scene_{i:04d}.{self.output_format}")
|
||||
if os.path.exists(frame_path):
|
||||
frames.append(KeyFrame(
|
||||
timestamp=ts,
|
||||
file_path=frame_path,
|
||||
width=video_info.get("width", 0),
|
||||
height=video_info.get("height", 0),
|
||||
))
|
||||
|
||||
# 如果场景检测帧太少,补充均匀采样
|
||||
if len(frames) < 5 and duration > 0:
|
||||
interval_result = await self.extract_at_intervals(
|
||||
video_path,
|
||||
interval_seconds=duration / 10,
|
||||
max_frames=10,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
if interval_result.success:
|
||||
# 合并并去重
|
||||
existing_ts = {f.timestamp for f in frames}
|
||||
for f in interval_result.frames:
|
||||
if f.timestamp not in existing_ts:
|
||||
frames.append(f)
|
||||
frames.sort(key=lambda x: x.timestamp)
|
||||
|
||||
return ExtractionResult(
|
||||
success=True,
|
||||
frames=frames[:max_frames],
|
||||
video_duration=duration,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ExtractionResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
def cleanup(self, output_dir: str) -> bool:
|
||||
"""
|
||||
清理提取的临时文件
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# 全局实例
|
||||
_extractor: Optional[KeyFrameExtractor] = None
|
||||
|
||||
|
||||
def get_keyframe_extractor() -> KeyFrameExtractor:
|
||||
"""获取关键帧提取器单例"""
|
||||
global _extractor
|
||||
if _extractor is None:
|
||||
_extractor = KeyFrameExtractor()
|
||||
return _extractor
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
风险分类服务
|
||||
根据违规类型判断风险等级
|
||||
"""
|
||||
from app.schemas.review import ViolationType, RiskLevel
|
||||
|
||||
|
||||
def classify_risk_level(violation_type: ViolationType) -> RiskLevel:
|
||||
"""
|
||||
根据违规类型分类风险等级
|
||||
|
||||
规则:
|
||||
- 高风险 (HIGH): 法律违规(广告法极限词、功效宣称)
|
||||
- 中风险 (MEDIUM): 平台规则违规(竞品露出、时长不足)
|
||||
- 低风险 (LOW): 品牌规范违规(品牌提及不足)
|
||||
|
||||
Args:
|
||||
violation_type: 违规类型
|
||||
|
||||
Returns:
|
||||
RiskLevel: 风险等级
|
||||
"""
|
||||
high_risk_types = {
|
||||
ViolationType.FORBIDDEN_WORD,
|
||||
ViolationType.EFFICACY_CLAIM,
|
||||
}
|
||||
|
||||
medium_risk_types = {
|
||||
ViolationType.COMPETITOR_LOGO,
|
||||
ViolationType.DURATION_SHORT,
|
||||
ViolationType.BRAND_SAFETY,
|
||||
}
|
||||
|
||||
low_risk_types = {
|
||||
ViolationType.MENTION_MISSING,
|
||||
}
|
||||
|
||||
if violation_type in high_risk_types:
|
||||
return RiskLevel.HIGH
|
||||
elif violation_type in medium_risk_types:
|
||||
return RiskLevel.MEDIUM
|
||||
elif violation_type in low_risk_types:
|
||||
return RiskLevel.LOW
|
||||
else:
|
||||
# 默认中风险
|
||||
return RiskLevel.MEDIUM
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
特例审批服务
|
||||
超时策略、审批流程
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.review import (
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
)
|
||||
|
||||
|
||||
# 超时时间(小时)
|
||||
TIMEOUT_HOURS = 48
|
||||
|
||||
|
||||
def apply_timeout_policy(
|
||||
record: RiskExceptionRecord,
|
||||
current_time: datetime,
|
||||
) -> RiskExceptionRecord:
|
||||
"""
|
||||
应用超时策略
|
||||
|
||||
规则:
|
||||
- 超过 48 小时未审批 → 自动拒绝
|
||||
- 记录自动拒绝原因
|
||||
|
||||
Args:
|
||||
record: 特例记录
|
||||
current_time: 当前时间
|
||||
|
||||
Returns:
|
||||
更新后的记录
|
||||
"""
|
||||
# 只处理待审批状态
|
||||
if record.status != RiskExceptionStatus.PENDING:
|
||||
return record
|
||||
|
||||
# 计算时间差
|
||||
apply_time = record.apply_time
|
||||
if isinstance(apply_time, str):
|
||||
apply_time = datetime.fromisoformat(apply_time.replace("Z", "+00:00"))
|
||||
|
||||
# 确保时区一致
|
||||
if apply_time.tzinfo is None:
|
||||
apply_time = apply_time.replace(tzinfo=timezone.utc)
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
elapsed = current_time - apply_time
|
||||
|
||||
if elapsed > timedelta(hours=TIMEOUT_HOURS):
|
||||
# 超时自动拒绝
|
||||
return RiskExceptionRecord(
|
||||
record_id=record.record_id,
|
||||
applicant_id=record.applicant_id,
|
||||
apply_time=record.apply_time,
|
||||
target_type=record.target_type,
|
||||
target_id=record.target_id,
|
||||
risk_rule_id=record.risk_rule_id,
|
||||
status=RiskExceptionStatus.REJECTED,
|
||||
valid_start_time=record.valid_start_time,
|
||||
valid_end_time=record.valid_end_time,
|
||||
reason_category=record.reason_category,
|
||||
justification=record.justification,
|
||||
attachment_url=record.attachment_url,
|
||||
current_approver_id=record.current_approver_id,
|
||||
approval_chain_log=record.approval_chain_log,
|
||||
auto_rejected=True,
|
||||
rejection_reason="timeout",
|
||||
last_status_at=current_time,
|
||||
)
|
||||
|
||||
return record
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
软性风控服务
|
||||
临界值、低置信度、历史记录触发警告
|
||||
"""
|
||||
from app.schemas.review import (
|
||||
SoftRiskContext,
|
||||
SoftRiskWarning,
|
||||
SoftRiskAction,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_soft_risk(context: SoftRiskContext) -> list[SoftRiskWarning]:
|
||||
"""
|
||||
评估软性风控
|
||||
|
||||
规则:
|
||||
- 违规率接近阈值(90% 以上)→ 二次确认
|
||||
- ASR/OCR 置信度 60%-80% → 备注提示
|
||||
- 有历史类似违规 → 备注提示
|
||||
|
||||
Args:
|
||||
context: 软性风控上下文
|
||||
|
||||
Returns:
|
||||
警告列表(可能为空)
|
||||
"""
|
||||
warnings: list[SoftRiskWarning] = []
|
||||
|
||||
# 1. 临界值检测
|
||||
if (
|
||||
context.violation_rate is not None
|
||||
and context.violation_threshold is not None
|
||||
and context.violation_threshold > 0
|
||||
):
|
||||
ratio = context.violation_rate / context.violation_threshold
|
||||
# 使用 round 避免浮点数精度问题 (0.045/0.05 = 0.8999999999999999)
|
||||
ratio = round(ratio, 10)
|
||||
if ratio >= 0.9 and ratio < 1.0:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="NEAR_THRESHOLD",
|
||||
message=f"违规率 {context.violation_rate:.1%} 接近阈值 {context.violation_threshold:.1%}",
|
||||
action_required=SoftRiskAction.CONFIRM,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 2. ASR 低置信度检测
|
||||
if context.asr_confidence is not None:
|
||||
if 0.6 <= context.asr_confidence < 0.8:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="LOW_CONFIDENCE_ASR",
|
||||
message=f"语音识别置信度较低 ({context.asr_confidence:.0%}),建议人工复核",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 3. OCR 低置信度检测
|
||||
if context.ocr_confidence is not None:
|
||||
if 0.6 <= context.ocr_confidence < 0.8:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="LOW_CONFIDENCE_OCR",
|
||||
message=f"字幕识别置信度较低 ({context.ocr_confidence:.0%}),建议人工复核",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
# 4. 历史违规检测
|
||||
if context.has_history_violation:
|
||||
warnings.append(SoftRiskWarning(
|
||||
code="HISTORY_RISK",
|
||||
message="该达人/内容存在历史类似违规记录",
|
||||
action_required=SoftRiskAction.NOTE,
|
||||
blocking=False,
|
||||
))
|
||||
|
||||
return warnings
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
视频下载服务
|
||||
从 URL 下载视频到临时目录,支持重试和进度回调
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
"""下载结果"""
|
||||
success: bool
|
||||
file_path: Optional[str] = None
|
||||
file_size: int = 0
|
||||
content_type: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class VideoDownloadService:
|
||||
"""视频下载服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
temp_dir: Optional[str] = None,
|
||||
max_file_size: int = 500 * 1024 * 1024, # 500MB
|
||||
timeout: float = 300.0, # 5 分钟
|
||||
chunk_size: int = 1024 * 1024, # 1MB
|
||||
):
|
||||
"""
|
||||
初始化下载服务
|
||||
|
||||
Args:
|
||||
temp_dir: 临时目录,默认使用系统临时目录
|
||||
max_file_size: 最大文件大小(字节)
|
||||
timeout: 下载超时(秒)
|
||||
chunk_size: 分块大小(字节)
|
||||
"""
|
||||
self.temp_dir = temp_dir or tempfile.gettempdir()
|
||||
self.max_file_size = max_file_size
|
||||
self.timeout = timeout
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
# 确保临时目录存在
|
||||
Path(self.temp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _generate_filename(self, url: str, content_type: Optional[str] = None) -> str:
|
||||
"""根据 URL 生成唯一文件名"""
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
|
||||
# 根据 content-type 确定扩展名
|
||||
ext = ".mp4"
|
||||
if content_type:
|
||||
ext_map = {
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-msvideo": ".avi",
|
||||
"video/x-matroska": ".mkv",
|
||||
}
|
||||
ext = ext_map.get(content_type, ".mp4")
|
||||
|
||||
return f"video_{url_hash}{ext}"
|
||||
|
||||
async def download(
|
||||
self,
|
||||
url: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
max_retries: int = 3,
|
||||
) -> DownloadResult:
|
||||
"""
|
||||
下载视频文件
|
||||
|
||||
Args:
|
||||
url: 视频 URL
|
||||
progress_callback: 进度回调函数 (downloaded_bytes, total_bytes)
|
||||
max_retries: 最大重试次数
|
||||
|
||||
Returns:
|
||||
DownloadResult: 下载结果
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = await self._download_once(url, progress_callback)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
|
||||
# 重试前等待
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"下载失败(已重试 {max_retries} 次): {last_error}",
|
||||
)
|
||||
|
||||
async def _download_once(
|
||||
self,
|
||||
url: str,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> DownloadResult:
|
||||
"""单次下载尝试"""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
# 先获取文件信息
|
||||
head_resp = await client.head(url)
|
||||
if head_resp.status_code >= 400:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"HTTP {head_resp.status_code}",
|
||||
)
|
||||
|
||||
content_type = head_resp.headers.get("content-type", "")
|
||||
content_length = int(head_resp.headers.get("content-length", 0))
|
||||
|
||||
# 检查文件大小
|
||||
if content_length > self.max_file_size:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"文件过大: {content_length / 1024 / 1024:.1f}MB > {self.max_file_size / 1024 / 1024:.1f}MB",
|
||||
)
|
||||
|
||||
# 检查是否为视频类型
|
||||
if content_type and not content_type.startswith("video/"):
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"非视频文件类型: {content_type}",
|
||||
)
|
||||
|
||||
# 生成本地文件路径
|
||||
filename = self._generate_filename(url, content_type)
|
||||
file_path = os.path.join(self.temp_dir, filename)
|
||||
|
||||
# 如果文件已存在且大小匹配,直接返回
|
||||
if os.path.exists(file_path):
|
||||
existing_size = os.path.getsize(file_path)
|
||||
if existing_size == content_length:
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
file_size=existing_size,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# 流式下载
|
||||
downloaded = 0
|
||||
async with client.stream("GET", url) as response:
|
||||
if response.status_code >= 400:
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=self.chunk_size):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# 检查是否超过最大限制
|
||||
if downloaded > self.max_file_size:
|
||||
os.remove(file_path)
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
error=f"文件过大,已下载 {downloaded / 1024 / 1024:.1f}MB",
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, content_length or downloaded)
|
||||
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=file_path,
|
||||
file_size=downloaded,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def cleanup(self, file_path: str) -> bool:
|
||||
"""
|
||||
清理下载的临时文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def cleanup_old_files(self, max_age_seconds: int = 3600) -> int:
|
||||
"""
|
||||
清理过期的临时文件
|
||||
|
||||
Args:
|
||||
max_age_seconds: 最大文件年龄(秒)
|
||||
|
||||
Returns:
|
||||
删除的文件数量
|
||||
"""
|
||||
import time
|
||||
|
||||
deleted = 0
|
||||
now = time.time()
|
||||
|
||||
for filename in os.listdir(self.temp_dir):
|
||||
if not filename.startswith("video_"):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(self.temp_dir, filename)
|
||||
try:
|
||||
file_age = now - os.path.getmtime(file_path)
|
||||
if file_age > max_age_seconds:
|
||||
os.remove(file_path)
|
||||
deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
# 全局实例
|
||||
_download_service: Optional[VideoDownloadService] = None
|
||||
|
||||
|
||||
def get_download_service() -> VideoDownloadService:
|
||||
"""获取下载服务单例"""
|
||||
global _download_service
|
||||
if _download_service is None:
|
||||
_download_service = VideoDownloadService()
|
||||
return _download_service
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
视频审核服务
|
||||
核心业务逻辑:违规检测、时长校验、风险分类、分数计算
|
||||
"""
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
class VideoReviewService:
|
||||
"""视频审核服务"""
|
||||
|
||||
def __init__(self):
|
||||
# AI 服务依赖(可注入 mock)
|
||||
self.asr_service: Optional[AsyncMock] = None
|
||||
self.cv_service: Optional[AsyncMock] = None
|
||||
self.ocr_service: Optional[AsyncMock] = None
|
||||
|
||||
async def detect_competitor_logos(
|
||||
self,
|
||||
frames: list[dict],
|
||||
competitors: list[str],
|
||||
min_confidence: float = 0.7,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测画面中的竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 视频帧数据,每帧包含 timestamp 和 objects
|
||||
competitors: 竞品列表
|
||||
min_confidence: 最小置信度阈值
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
for frame in frames:
|
||||
timestamp = frame.get("timestamp", 0.0)
|
||||
objects = frame.get("objects", [])
|
||||
|
||||
for obj in objects:
|
||||
label = obj.get("label", "")
|
||||
confidence = obj.get("confidence", 0.0)
|
||||
|
||||
if label in competitors and confidence >= min_confidence:
|
||||
violations.append({
|
||||
"type": "competitor_logo",
|
||||
"timestamp": timestamp,
|
||||
"content": label,
|
||||
"confidence": confidence,
|
||||
"risk_level": "medium",
|
||||
"suggestion": f"请移除画面中的竞品露出:{label}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def detect_forbidden_words_in_speech(
|
||||
self,
|
||||
transcript: list[dict],
|
||||
forbidden_words: list[str],
|
||||
context_aware: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测语音转文字中的违禁词
|
||||
|
||||
Args:
|
||||
transcript: ASR 转写结果,每段包含 text, start, end
|
||||
forbidden_words: 违禁词列表
|
||||
context_aware: 是否启用语境感知
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
|
||||
# 广告语境关键词
|
||||
ad_context_keywords = ["产品", "购买", "推荐", "选择", "品牌", "效果"]
|
||||
|
||||
for segment in transcript:
|
||||
text = segment.get("text", "")
|
||||
start = segment.get("start", 0.0)
|
||||
|
||||
for word in forbidden_words:
|
||||
if word in text:
|
||||
# 语境感知检测
|
||||
if context_aware:
|
||||
is_ad_context = any(kw in text for kw in ad_context_keywords)
|
||||
if not is_ad_context:
|
||||
continue # 非广告语境,跳过
|
||||
|
||||
violations.append({
|
||||
"type": "forbidden_word",
|
||||
"content": word,
|
||||
"timestamp": start,
|
||||
"source": "speech",
|
||||
"risk_level": "high",
|
||||
"suggestion": f"建议删除或替换违禁词:{word}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def detect_forbidden_words_in_subtitle(
|
||||
self,
|
||||
subtitles: list[dict],
|
||||
forbidden_words: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测字幕中的违禁词
|
||||
|
||||
Args:
|
||||
subtitles: OCR 提取的字幕,每条包含 text, timestamp
|
||||
forbidden_words: 违禁词列表
|
||||
|
||||
Returns:
|
||||
违规列表
|
||||
"""
|
||||
violations = []
|
||||
|
||||
for subtitle in subtitles:
|
||||
text = subtitle.get("text", "")
|
||||
timestamp = subtitle.get("timestamp", 0.0)
|
||||
|
||||
for word in forbidden_words:
|
||||
if word in text:
|
||||
violations.append({
|
||||
"type": "forbidden_word",
|
||||
"content": word,
|
||||
"timestamp": timestamp,
|
||||
"source": "subtitle",
|
||||
"risk_level": "high",
|
||||
"suggestion": f"建议删除字幕中的违禁词:{word}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def check_product_display_duration(
|
||||
self,
|
||||
appearances: list[dict],
|
||||
min_seconds: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
校验产品同框时长
|
||||
|
||||
Args:
|
||||
appearances: 产品出现时间段列表,每段包含 start, end
|
||||
min_seconds: 最小要求秒数
|
||||
|
||||
Returns:
|
||||
违规列表(如果时长不足)
|
||||
"""
|
||||
total_duration = 0.0
|
||||
for appearance in appearances:
|
||||
start = appearance.get("start", 0.0)
|
||||
end = appearance.get("end", 0.0)
|
||||
total_duration += (end - start)
|
||||
|
||||
if total_duration < min_seconds:
|
||||
return [{
|
||||
"type": "duration_short",
|
||||
"content": f"产品同框时长 {total_duration:.0f} 秒,不足要求的 {min_seconds} 秒",
|
||||
"timestamp": 0.0,
|
||||
"risk_level": "medium",
|
||||
"suggestion": f"建议增加产品同框时长至 {min_seconds} 秒以上",
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
async def check_brand_mention_frequency(
|
||||
self,
|
||||
transcript: list[dict],
|
||||
brand_name: str,
|
||||
min_mentions: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
校验品牌提及频次
|
||||
|
||||
Args:
|
||||
transcript: ASR 转写结果
|
||||
brand_name: 品牌名称
|
||||
min_mentions: 最小提及次数
|
||||
|
||||
Returns:
|
||||
违规列表(如果提及不足)
|
||||
"""
|
||||
mention_count = 0
|
||||
for segment in transcript:
|
||||
text = segment.get("text", "")
|
||||
mention_count += text.count(brand_name)
|
||||
|
||||
if mention_count < min_mentions:
|
||||
return [{
|
||||
"type": "mention_missing",
|
||||
"content": f"品牌 '{brand_name}' 提及 {mention_count} 次,不足要求的 {min_mentions} 次",
|
||||
"timestamp": 0.0,
|
||||
"risk_level": "low",
|
||||
"suggestion": f"建议增加品牌提及至 {min_mentions} 次以上",
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
def classify_risk_level(self, violation: dict) -> str:
|
||||
"""
|
||||
根据违规项分类风险等级
|
||||
|
||||
Args:
|
||||
violation: 违规项
|
||||
|
||||
Returns:
|
||||
风险等级: high/medium/low
|
||||
"""
|
||||
violation_type = violation.get("type", "")
|
||||
category = violation.get("category", "")
|
||||
|
||||
# 法律违规 -> 高风险
|
||||
if category == "absolute_term" or violation_type == "forbidden_word":
|
||||
return "high"
|
||||
|
||||
# 平台规则违规 -> 中风险
|
||||
if category == "platform_rule" or violation_type in ["duration_short", "competitor_logo"]:
|
||||
return "medium"
|
||||
|
||||
# 品牌规范违规 -> 低风险
|
||||
if category == "brand_guideline" or violation_type == "mention_missing":
|
||||
return "low"
|
||||
|
||||
return "medium" # 默认中风险
|
||||
|
||||
def calculate_score(self, violations: list[dict]) -> int:
|
||||
"""
|
||||
计算合规分数
|
||||
|
||||
规则:
|
||||
- 基础分 100 分
|
||||
- 高风险违规扣 25 分
|
||||
- 中风险违规扣 15 分
|
||||
- 低风险违规扣 5 分
|
||||
- 最低 0 分
|
||||
|
||||
Args:
|
||||
violations: 违规列表
|
||||
|
||||
Returns:
|
||||
合规分数 (0-100)
|
||||
"""
|
||||
score = 100
|
||||
|
||||
for violation in violations:
|
||||
risk_level = violation.get("risk_level", "medium")
|
||||
|
||||
if risk_level == "high":
|
||||
score -= 25
|
||||
elif risk_level == "medium":
|
||||
score -= 15
|
||||
else:
|
||||
score -= 5
|
||||
|
||||
return max(0, score)
|
||||
|
||||
async def review_video(
|
||||
self,
|
||||
video_url: str,
|
||||
platform: str,
|
||||
brand_id: str,
|
||||
competitors: list[str] = None,
|
||||
forbidden_words: list[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
完整视频审核流程
|
||||
|
||||
Args:
|
||||
video_url: 视频 URL
|
||||
platform: 投放平台
|
||||
brand_id: 品牌 ID
|
||||
competitors: 竞品列表
|
||||
forbidden_words: 违禁词列表
|
||||
|
||||
Returns:
|
||||
审核结果
|
||||
"""
|
||||
competitors = competitors or []
|
||||
forbidden_words = forbidden_words or []
|
||||
all_violations = []
|
||||
|
||||
# 1. ASR 语音转文字 + 违禁词检测
|
||||
if self.asr_service:
|
||||
transcript = await self.asr_service.transcribe(video_url)
|
||||
speech_violations = await self.detect_forbidden_words_in_speech(
|
||||
transcript, forbidden_words
|
||||
)
|
||||
all_violations.extend(speech_violations)
|
||||
|
||||
# 2. CV 物体检测 + 竞品 Logo 检测
|
||||
if self.cv_service:
|
||||
frames = await self.cv_service.detect_objects(video_url)
|
||||
logo_violations = await self.detect_competitor_logos(frames, competitors)
|
||||
all_violations.extend(logo_violations)
|
||||
|
||||
# 3. OCR 字幕提取 + 违禁词检测
|
||||
if self.ocr_service:
|
||||
subtitles = await self.ocr_service.extract_subtitles(video_url)
|
||||
subtitle_violations = await self.detect_forbidden_words_in_subtitle(
|
||||
subtitles, forbidden_words
|
||||
)
|
||||
all_violations.extend(subtitle_violations)
|
||||
|
||||
# 4. 计算分数
|
||||
score = self.calculate_score(all_violations)
|
||||
|
||||
# 5. 生成摘要
|
||||
if not all_violations:
|
||||
summary = "视频内容合规,未发现违规项"
|
||||
else:
|
||||
summary = f"发现 {len(all_violations)} 处违规"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": summary,
|
||||
"violations": all_violations,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
视觉分析服务
|
||||
集成 GPT-4V 实现竞品 Logo 检测、画面分析、OCR 字幕提取
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.keyframe import KeyFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedObject:
|
||||
"""检测到的对象"""
|
||||
label: str
|
||||
confidence: float
|
||||
timestamp: float
|
||||
bounding_box: Optional[dict] = None # {x, y, width, height}
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleSegment:
|
||||
"""字幕片段"""
|
||||
text: str
|
||||
timestamp: float
|
||||
confidence: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisionAnalysisResult:
|
||||
"""视觉分析结果"""
|
||||
success: bool
|
||||
detected_logos: list[DetectedObject] = field(default_factory=list)
|
||||
detected_texts: list[SubtitleSegment] = field(default_factory=list)
|
||||
scene_description: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class VisionAnalysisService:
|
||||
"""视觉分析服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
max_tokens: int = 2000,
|
||||
):
|
||||
"""
|
||||
初始化视觉分析服务
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
base_url: API 基础 URL
|
||||
model: 视觉模型名称
|
||||
max_tokens: 最大输出 token
|
||||
"""
|
||||
self.client = OpenAICompatibleClient(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
self.model = model
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
async def detect_logos(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitor_names: list[str],
|
||||
batch_size: int = 5,
|
||||
) -> VisionAnalysisResult:
|
||||
"""
|
||||
检测画面中的竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 关键帧列表
|
||||
competitor_names: 竞品名称列表
|
||||
batch_size: 每批处理的帧数
|
||||
|
||||
Returns:
|
||||
VisionAnalysisResult: 分析结果
|
||||
"""
|
||||
if not frames:
|
||||
return VisionAnalysisResult(success=True)
|
||||
|
||||
all_logos = []
|
||||
competitors_str = "、".join(competitor_names) if competitor_names else "任何品牌"
|
||||
|
||||
# 分批处理帧
|
||||
for i in range(0, len(frames), batch_size):
|
||||
batch = frames[i:i + batch_size]
|
||||
|
||||
try:
|
||||
result = await self._analyze_frames_for_logos(
|
||||
batch,
|
||||
competitors_str,
|
||||
)
|
||||
all_logos.extend(result)
|
||||
except Exception as e:
|
||||
# 单批失败不影响整体
|
||||
continue
|
||||
|
||||
return VisionAnalysisResult(
|
||||
success=True,
|
||||
detected_logos=all_logos,
|
||||
)
|
||||
|
||||
async def _analyze_frames_for_logos(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitors_str: str,
|
||||
) -> list[DetectedObject]:
|
||||
"""分析一批帧中的 Logo"""
|
||||
# 构建图片内容
|
||||
image_contents = []
|
||||
timestamps = []
|
||||
|
||||
for frame in frames:
|
||||
base64_image = frame.to_base64()
|
||||
image_contents.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "low",
|
||||
},
|
||||
})
|
||||
timestamps.append(frame.timestamp)
|
||||
|
||||
prompt = f"""分析这些视频帧,检测是否出现以下竞品品牌的 Logo 或产品:{competitors_str}
|
||||
|
||||
请以 JSON 格式返回检测结果,格式如下:
|
||||
{{
|
||||
"detections": [
|
||||
{{
|
||||
"frame_index": 0,
|
||||
"brand": "品牌名称",
|
||||
"confidence": 0.9,
|
||||
"description": "Logo 出现在画面左上角"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
如果没有检测到任何竞品,返回空数组:{{"detections": []}}
|
||||
只返回 JSON,不要其他文字。"""
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}] + image_contents,
|
||||
}]
|
||||
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.1,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
|
||||
# 解析响应
|
||||
try:
|
||||
content = response.content.strip()
|
||||
# 尝试提取 JSON
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0]
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0]
|
||||
|
||||
data = json.loads(content)
|
||||
detections = data.get("detections", [])
|
||||
|
||||
result = []
|
||||
for det in detections:
|
||||
frame_idx = det.get("frame_index", 0)
|
||||
if 0 <= frame_idx < len(timestamps):
|
||||
result.append(DetectedObject(
|
||||
label=det.get("brand", ""),
|
||||
confidence=det.get("confidence", 0.8),
|
||||
timestamp=timestamps[frame_idx],
|
||||
description=det.get("description", ""),
|
||||
))
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
async def extract_text_from_frames(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
batch_size: int = 5,
|
||||
) -> VisionAnalysisResult:
|
||||
"""
|
||||
从帧中提取文字(OCR)
|
||||
|
||||
Args:
|
||||
frames: 关键帧列表
|
||||
batch_size: 每批处理的帧数
|
||||
|
||||
Returns:
|
||||
VisionAnalysisResult: 分析结果
|
||||
"""
|
||||
if not frames:
|
||||
return VisionAnalysisResult(success=True)
|
||||
|
||||
all_texts = []
|
||||
|
||||
for i in range(0, len(frames), batch_size):
|
||||
batch = frames[i:i + batch_size]
|
||||
|
||||
try:
|
||||
result = await self._extract_text_from_batch(batch)
|
||||
all_texts.extend(result)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return VisionAnalysisResult(
|
||||
success=True,
|
||||
detected_texts=all_texts,
|
||||
)
|
||||
|
||||
async def _extract_text_from_batch(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
) -> list[SubtitleSegment]:
|
||||
"""从一批帧中提取文字"""
|
||||
image_contents = []
|
||||
timestamps = []
|
||||
|
||||
for frame in frames:
|
||||
base64_image = frame.to_base64()
|
||||
image_contents.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "high",
|
||||
},
|
||||
})
|
||||
timestamps.append(frame.timestamp)
|
||||
|
||||
prompt = """提取这些视频帧中的所有可见文字,特别是字幕和标题。
|
||||
|
||||
请以 JSON 格式返回,格式如下:
|
||||
{
|
||||
"texts": [
|
||||
{
|
||||
"frame_index": 0,
|
||||
"text": "提取到的文字内容",
|
||||
"type": "subtitle"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
type 可以是: subtitle(字幕), title(标题), caption(说明文字), other(其他)
|
||||
如果没有文字,返回空数组:{"texts": []}
|
||||
只返回 JSON,不要其他文字。"""
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}] + image_contents,
|
||||
}]
|
||||
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.1,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
|
||||
try:
|
||||
content = response.content.strip()
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0]
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0]
|
||||
|
||||
data = json.loads(content)
|
||||
texts = data.get("texts", [])
|
||||
|
||||
result = []
|
||||
for txt in texts:
|
||||
frame_idx = txt.get("frame_index", 0)
|
||||
if 0 <= frame_idx < len(timestamps):
|
||||
text_content = txt.get("text", "").strip()
|
||||
if text_content:
|
||||
result.append(SubtitleSegment(
|
||||
text=text_content,
|
||||
timestamp=timestamps[frame_idx],
|
||||
))
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return []
|
||||
|
||||
async def analyze_scene(
|
||||
self,
|
||||
frame: KeyFrame,
|
||||
context: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
分析单帧场景
|
||||
|
||||
Args:
|
||||
frame: 关键帧
|
||||
context: 额外上下文
|
||||
|
||||
Returns:
|
||||
场景描述
|
||||
"""
|
||||
base64_image = frame.to_base64()
|
||||
|
||||
prompt = f"请简要描述这个视频画面的内容,特别关注:产品、人物、场景、文字。{context}"
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"detail": "low",
|
||||
},
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
try:
|
||||
response = await self.client.chat_completion(
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=0.3,
|
||||
max_tokens=500,
|
||||
)
|
||||
return response.content.strip()
|
||||
except Exception as e:
|
||||
return f"分析失败: {str(e)}"
|
||||
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
await self.client.close()
|
||||
|
||||
|
||||
class CompetitorLogoDetector:
|
||||
"""竞品 Logo 检测器(封装简化接口)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
):
|
||||
self.service = VisionAnalysisService(api_key, base_url, model)
|
||||
|
||||
async def detect(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
competitors: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
检测竞品 Logo
|
||||
|
||||
Args:
|
||||
frames: 关键帧
|
||||
competitors: 竞品列表
|
||||
|
||||
Returns:
|
||||
违规列表(兼容 VideoReviewService 格式)
|
||||
"""
|
||||
result = await self.service.detect_logos(frames, competitors)
|
||||
|
||||
violations = []
|
||||
for logo in result.detected_logos:
|
||||
if logo.label in competitors or any(c in logo.label for c in competitors):
|
||||
violations.append({
|
||||
"type": "competitor_logo",
|
||||
"timestamp": logo.timestamp,
|
||||
"timestamp_end": logo.timestamp + 1.0,
|
||||
"content": logo.label,
|
||||
"confidence": logo.confidence,
|
||||
"risk_level": "medium",
|
||||
"source": "visual",
|
||||
"suggestion": f"请移除画面中的竞品露出:{logo.label}",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
async def close(self):
|
||||
await self.service.close()
|
||||
|
||||
|
||||
class VideoOCRService:
|
||||
"""视频 OCR 服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o",
|
||||
):
|
||||
self.service = VisionAnalysisService(api_key, base_url, model)
|
||||
|
||||
async def extract_subtitles(
|
||||
self,
|
||||
frames: list[KeyFrame],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
提取字幕
|
||||
|
||||
Args:
|
||||
frames: 关键帧
|
||||
|
||||
Returns:
|
||||
字幕列表(兼容 VideoReviewService 格式)
|
||||
"""
|
||||
result = await self.service.extract_text_from_frames(frames)
|
||||
|
||||
subtitles = []
|
||||
for seg in result.detected_texts:
|
||||
subtitles.append({
|
||||
"text": seg.text,
|
||||
"timestamp": seg.timestamp,
|
||||
})
|
||||
|
||||
return subtitles
|
||||
|
||||
async def close(self):
|
||||
await self.service.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""后台任务模块"""
|
||||
from app.celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
视频审核后台任务
|
||||
完整的视频审核流程:下载 → 提取帧 → ASR → 视觉分析 → 生成报告
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus
|
||||
from app.models.rule import ForbiddenWord, Competitor
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.services.video_download import VideoDownloadService, DownloadResult
|
||||
from app.services.keyframe import KeyFrameExtractor, ExtractionResult
|
||||
from app.services.asr import VideoASRService, TranscriptionResult
|
||||
from app.services.vision import CompetitorLogoDetector, VideoOCRService
|
||||
from app.services.video_review import VideoReviewService
|
||||
from app.utils.crypto import decrypt_api_key
|
||||
|
||||
|
||||
# 异步数据库引擎
|
||||
_async_engine = None
|
||||
_async_session_factory = None
|
||||
|
||||
|
||||
def get_async_engine():
|
||||
"""获取异步数据库引擎"""
|
||||
global _async_engine
|
||||
if _async_engine is None:
|
||||
_async_engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
return _async_engine
|
||||
|
||||
|
||||
def get_async_session() -> sessionmaker:
|
||||
"""获取异步会话工厂"""
|
||||
global _async_session_factory
|
||||
if _async_session_factory is None:
|
||||
_async_session_factory = sessionmaker(
|
||||
get_async_engine(),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _async_session_factory
|
||||
|
||||
|
||||
async def update_review_progress(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
progress: int,
|
||||
current_step: str,
|
||||
status: Optional[DBTaskStatus] = None,
|
||||
):
|
||||
"""更新审核进度"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.progress = progress
|
||||
task.current_step = current_step
|
||||
if status:
|
||||
task.status = status
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def complete_review(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
score: int,
|
||||
summary: str,
|
||||
violations: list[dict],
|
||||
status: DBTaskStatus = DBTaskStatus.COMPLETED,
|
||||
):
|
||||
"""完成审核"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = status
|
||||
task.progress = 100
|
||||
task.current_step = "完成"
|
||||
task.score = score
|
||||
task.summary = summary
|
||||
task.violations = violations
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def fail_review(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
error: str,
|
||||
):
|
||||
"""审核失败"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = DBTaskStatus.FAILED
|
||||
task.current_step = "失败"
|
||||
task.summary = f"审核失败: {error}"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_ai_config(db: AsyncSession, tenant_id: str) -> Optional[dict]:
|
||||
"""获取租户 AI 配置"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return {
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"base_url": config.base_url,
|
||||
"models": config.models,
|
||||
}
|
||||
|
||||
|
||||
async def get_forbidden_words(db: AsyncSession, tenant_id: str) -> list[str]:
|
||||
"""获取违禁词列表"""
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord.word).where(ForbiddenWord.tenant_id == tenant_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_competitors(db: AsyncSession, tenant_id: str, brand_id: str) -> list[str]:
|
||||
"""获取竞品列表"""
|
||||
result = await db.execute(
|
||||
select(Competitor.name).where(
|
||||
Competitor.tenant_id == tenant_id,
|
||||
Competitor.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def process_video_review(
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
video_url: str,
|
||||
brand_id: str,
|
||||
platform: str,
|
||||
):
|
||||
"""
|
||||
处理视频审核(异步核心逻辑)
|
||||
|
||||
流程:
|
||||
1. 下载视频
|
||||
2. 提取关键帧
|
||||
3. ASR 语音转写
|
||||
4. 视觉分析(竞品 Logo 检测)
|
||||
5. OCR 字幕提取
|
||||
6. 违规检测
|
||||
7. 生成报告
|
||||
"""
|
||||
session_factory = get_async_session()
|
||||
download_service = VideoDownloadService()
|
||||
keyframe_extractor = KeyFrameExtractor()
|
||||
review_service = VideoReviewService()
|
||||
|
||||
video_path = None
|
||||
frames_dir = None
|
||||
logo_detector = None
|
||||
ocr_service = None
|
||||
asr_service = None
|
||||
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
# 更新状态:处理中
|
||||
await update_review_progress(
|
||||
db, review_id, 5, "开始处理",
|
||||
status=DBTaskStatus.PROCESSING,
|
||||
)
|
||||
|
||||
# 获取 AI 配置
|
||||
ai_config = await get_ai_config(db, tenant_id)
|
||||
if not ai_config:
|
||||
await fail_review(db, review_id, "AI 服务未配置")
|
||||
return
|
||||
|
||||
# 获取规则
|
||||
forbidden_words = await get_forbidden_words(db, tenant_id)
|
||||
competitors = await get_competitors(db, tenant_id, brand_id)
|
||||
|
||||
# 初始化 AI 服务
|
||||
api_key = ai_config["api_key"]
|
||||
base_url = ai_config["base_url"]
|
||||
models = ai_config["models"]
|
||||
|
||||
asr_service = VideoASRService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("audio", "whisper-1"),
|
||||
)
|
||||
logo_detector = CompetitorLogoDetector(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("vision", "gpt-4o"),
|
||||
)
|
||||
ocr_service = VideoOCRService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("vision", "gpt-4o"),
|
||||
)
|
||||
|
||||
# 1. 下载视频
|
||||
await update_review_progress(db, review_id, 10, "下载视频")
|
||||
download_result: DownloadResult = await download_service.download(video_url)
|
||||
if not download_result.success:
|
||||
await fail_review(db, review_id, f"视频下载失败: {download_result.error}")
|
||||
return
|
||||
video_path = download_result.file_path
|
||||
|
||||
# 2. 提取关键帧
|
||||
await update_review_progress(db, review_id, 25, "提取关键帧")
|
||||
extraction_result: ExtractionResult = await keyframe_extractor.extract_at_intervals(
|
||||
video_path,
|
||||
interval_seconds=2.0,
|
||||
max_frames=30,
|
||||
)
|
||||
if not extraction_result.success:
|
||||
await fail_review(db, review_id, f"关键帧提取失败: {extraction_result.error}")
|
||||
return
|
||||
frames_dir = extraction_result.output_dir
|
||||
frames = extraction_result.frames
|
||||
|
||||
all_violations = []
|
||||
|
||||
# 3. ASR 语音转写
|
||||
await update_review_progress(db, review_id, 40, "语音转写")
|
||||
transcript_result: TranscriptionResult = await asr_service.transcribe_video(video_path)
|
||||
transcript = []
|
||||
if transcript_result.success:
|
||||
transcript = [
|
||||
{"text": seg.text, "start": seg.start, "end": seg.end}
|
||||
for seg in transcript_result.segments
|
||||
]
|
||||
|
||||
# 检测口播违禁词
|
||||
speech_violations = await review_service.detect_forbidden_words_in_speech(
|
||||
transcript,
|
||||
forbidden_words,
|
||||
context_aware=True,
|
||||
)
|
||||
all_violations.extend(speech_violations)
|
||||
|
||||
# 4. 视觉分析 - 竞品 Logo 检测
|
||||
await update_review_progress(db, review_id, 60, "检测竞品 Logo")
|
||||
if competitors and frames:
|
||||
logo_violations = await logo_detector.detect(frames, competitors)
|
||||
all_violations.extend(logo_violations)
|
||||
|
||||
# 5. OCR 字幕提取
|
||||
await update_review_progress(db, review_id, 75, "提取字幕")
|
||||
if frames:
|
||||
subtitles = await ocr_service.extract_subtitles(frames)
|
||||
|
||||
# 检测字幕违禁词
|
||||
subtitle_violations = await review_service.detect_forbidden_words_in_subtitle(
|
||||
subtitles,
|
||||
forbidden_words,
|
||||
)
|
||||
all_violations.extend(subtitle_violations)
|
||||
|
||||
# 6. 计算分数和生成报告
|
||||
await update_review_progress(db, review_id, 90, "生成报告")
|
||||
score = review_service.calculate_score(all_violations)
|
||||
|
||||
if not all_violations:
|
||||
summary = "视频内容合规,未发现违规项"
|
||||
else:
|
||||
high_count = sum(1 for v in all_violations if v.get("risk_level") == "high")
|
||||
medium_count = sum(1 for v in all_violations if v.get("risk_level") == "medium")
|
||||
summary = f"发现 {len(all_violations)} 处违规"
|
||||
if high_count > 0:
|
||||
summary += f"({high_count} 处高风险)"
|
||||
|
||||
# 7. 完成审核
|
||||
await complete_review(
|
||||
db,
|
||||
review_id,
|
||||
score=score,
|
||||
summary=summary,
|
||||
violations=all_violations,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await fail_review(db, review_id, str(e))
|
||||
|
||||
finally:
|
||||
# 清理资源
|
||||
if video_path:
|
||||
download_service.cleanup(video_path)
|
||||
if frames_dir:
|
||||
keyframe_extractor.cleanup(frames_dir)
|
||||
if logo_detector:
|
||||
await logo_detector.close()
|
||||
if ocr_service:
|
||||
await ocr_service.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="app.tasks.review.process_video_review_task",
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
)
|
||||
def process_video_review_task(
|
||||
self,
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
video_url: str,
|
||||
brand_id: str,
|
||||
platform: str,
|
||||
):
|
||||
"""
|
||||
视频审核 Celery 任务
|
||||
|
||||
Args:
|
||||
review_id: 审核任务 ID
|
||||
tenant_id: 租户 ID
|
||||
video_url: 视频 URL
|
||||
brand_id: 品牌 ID
|
||||
platform: 平台
|
||||
"""
|
||||
try:
|
||||
# 运行异步任务
|
||||
asyncio.run(process_video_review(
|
||||
review_id=review_id,
|
||||
tenant_id=tenant_id,
|
||||
video_url=video_url,
|
||||
brand_id=brand_id,
|
||||
platform=platform,
|
||||
))
|
||||
except Exception as e:
|
||||
# 重试
|
||||
raise self.retry(exc=e)
|
||||
|
||||
|
||||
@shared_task(name="app.tasks.review.cleanup_old_files_task")
|
||||
def cleanup_old_files_task():
|
||||
"""清理过期的临时文件"""
|
||||
from app.services.video_download import get_download_service
|
||||
|
||||
service = get_download_service()
|
||||
deleted = service.cleanup_old_files(max_age_seconds=3600)
|
||||
return {"deleted_files": deleted}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
__all__ = [
|
||||
"encrypt_api_key",
|
||||
"decrypt_api_key",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
加密工具
|
||||
API Key 加解密
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""
|
||||
获取 Fernet 加密器
|
||||
使用应用的 SECRET_KEY 派生加密密钥
|
||||
"""
|
||||
# 使用 PBKDF2 从 SECRET_KEY 派生 32 字节密钥
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b"miaosi-api-key-salt", # 固定 salt,生产环境应配置为环境变量
|
||||
iterations=100000,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(
|
||||
kdf.derive(settings.SECRET_KEY.encode())
|
||||
)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_api_key(api_key: str) -> str:
|
||||
"""
|
||||
加密 API Key
|
||||
|
||||
Args:
|
||||
api_key: 明文 API Key
|
||||
|
||||
Returns:
|
||||
加密后的 Base64 字符串
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
fernet = _get_fernet()
|
||||
encrypted = fernet.encrypt(api_key.encode())
|
||||
return encrypted.decode()
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted: str) -> str:
|
||||
"""
|
||||
解密 API Key
|
||||
|
||||
Args:
|
||||
encrypted: 加密的 API Key
|
||||
|
||||
Returns:
|
||||
明文 API Key
|
||||
"""
|
||||
if not encrypted:
|
||||
return ""
|
||||
|
||||
fernet = _get_fernet()
|
||||
decrypted = fernet.decrypt(encrypted.encode())
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""
|
||||
脱敏 API Key
|
||||
|
||||
Args:
|
||||
api_key: API Key(明文或加密均可)
|
||||
|
||||
Returns:
|
||||
脱敏后的字符串,如 "sk-1234****5678"
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
|
||||
return f"{api_key[:4]}****{api_key[-4:]}"
|
||||
@@ -0,0 +1,95 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL 数据库
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: miaosi-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: miaosi
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis 缓存/消息队列
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: miaosi-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# FastAPI 后端服务
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: miaosi-api
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
DEBUG: "true"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- video_temp:/tmp/videos
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Celery Worker
|
||||
celery-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: miaosi-celery-worker
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- video_temp:/tmp/videos
|
||||
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
||||
|
||||
# Celery Beat (定时任务调度器)
|
||||
celery-beat:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: miaosi-celery-beat
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
- celery-worker
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
command: celery -A app.celery_app beat -l info
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
video_temp:
|
||||
@@ -0,0 +1,76 @@
|
||||
[project]
|
||||
name = "miaosi-backend"
|
||||
version = "1.0.0"
|
||||
description = "秒思智能审核平台后端服务"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn>=0.27.0",
|
||||
"celery>=5.3.0",
|
||||
"redis>=5.0.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"greenlet>=3.0.0",
|
||||
"httpx>=0.26.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-jose>=3.3.0",
|
||||
"passlib>=1.7.4",
|
||||
"alembic>=1.13.0",
|
||||
"cryptography>=42.0.0",
|
||||
"openai>=1.12.0",
|
||||
"cachetools>=5.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"httpx>=0.26.0",
|
||||
"testcontainers>=3.7.0",
|
||||
"factory-boy>=3.3.0",
|
||||
"faker>=22.0.0",
|
||||
"respx>=0.20.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --tb=short --strict-markers"
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks tests as integration tests",
|
||||
"e2e: marks tests as end-to-end tests",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/migrations/*",
|
||||
"*/__init__.py",
|
||||
"*/tests/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# 开发环境快速启动脚本
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 秒思智能审核平台 - 开发环境启动 ==="
|
||||
|
||||
# 检查 Docker 是否运行
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "错误: Docker 未运行,请先启动 Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 启动基础服务 (PostgreSQL + Redis)
|
||||
echo "启动 PostgreSQL 和 Redis..."
|
||||
docker-compose up -d postgres redis
|
||||
|
||||
# 等待服务就绪
|
||||
echo "等待服务就绪..."
|
||||
sleep 5
|
||||
|
||||
# 运行数据库迁移
|
||||
echo "运行数据库迁移..."
|
||||
alembic upgrade head
|
||||
|
||||
echo ""
|
||||
echo "=== 基础服务已启动 ==="
|
||||
echo "PostgreSQL: localhost:5432"
|
||||
echo "Redis: localhost:6379"
|
||||
echo ""
|
||||
echo "启动后端服务:"
|
||||
echo " uvicorn app.main:app --reload"
|
||||
echo ""
|
||||
echo "启动 Celery Worker:"
|
||||
echo " celery -A app.celery_app worker -l info -Q default,review"
|
||||
echo ""
|
||||
echo "启动 Celery Beat (可选):"
|
||||
echo " celery -A app.celery_app beat -l info"
|
||||
@@ -0,0 +1 @@
|
||||
"""测试模块"""
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
pytest 配置和 fixtures
|
||||
测试覆盖: 数据库会话、HTTP 客户端、Mock 数据
|
||||
使用 app.dependency_overrides 实现测试隔离(支持并行测试)
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.base import Base
|
||||
from app.services.health import (
|
||||
MockHealthChecker,
|
||||
get_health_checker,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""创建事件循环(session 级别)"""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ==================== 数据库测试 Fixtures ====================
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def test_db_engine():
|
||||
"""创建测试数据库引擎(使用 SQLite 内存数据库)"""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# 创建所有表
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# 清理
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def test_db_session(test_db_engine):
|
||||
"""创建测试数据库会话"""
|
||||
async_session_factory = sessionmaker(
|
||||
test_db_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(test_db_session) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""
|
||||
创建异步测试客户端(使用测试数据库)
|
||||
|
||||
Yields:
|
||||
AsyncClient: httpx 异步客户端
|
||||
"""
|
||||
# 覆盖数据库依赖
|
||||
async def override_get_db():
|
||||
yield test_db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
# 每个测试结束后清理 dependency_overrides
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_no_db() -> AsyncGenerator[AsyncClient, None]:
|
||||
"""
|
||||
创建异步测试客户端(不使用数据库,用于简单测试)
|
||||
|
||||
Yields:
|
||||
AsyncClient: httpx 异步客户端
|
||||
"""
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_health_checker(client: AsyncClient):
|
||||
"""
|
||||
创建 Mock 健康检查器(所有依赖健康)
|
||||
使用 FastAPI dependency_overrides 实现隔离
|
||||
|
||||
Yields:
|
||||
MockHealthChecker: mock 实例
|
||||
"""
|
||||
checker = MockHealthChecker(database_healthy=True, redis_healthy=True)
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
yield checker
|
||||
# 清理由 client fixture 统一处理
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_unhealthy_db_checker(client: AsyncClient):
|
||||
"""
|
||||
创建 Mock 健康检查器(数据库不健康)
|
||||
|
||||
Yields:
|
||||
MockHealthChecker: mock 实例
|
||||
"""
|
||||
checker = MockHealthChecker(database_healthy=False, redis_healthy=True)
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
yield checker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_unhealthy_redis_checker(client: AsyncClient):
|
||||
"""
|
||||
创建 Mock 健康检查器(Redis 不健康)
|
||||
|
||||
Yields:
|
||||
MockHealthChecker: mock 实例
|
||||
"""
|
||||
checker = MockHealthChecker(database_healthy=True, redis_healthy=False)
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
yield checker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_all_unhealthy_checker(client: AsyncClient):
|
||||
"""
|
||||
创建 Mock 健康检查器(所有依赖不健康)
|
||||
|
||||
Yields:
|
||||
MockHealthChecker: mock 实例
|
||||
"""
|
||||
checker = MockHealthChecker(database_healthy=False, redis_healthy=False)
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
yield checker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_settings():
|
||||
"""
|
||||
获取应用配置(用于测试断言)
|
||||
|
||||
Returns:
|
||||
Settings: 应用配置实例
|
||||
"""
|
||||
return settings
|
||||
|
||||
|
||||
# ==================== 通用测试数据 Fixtures ====================
|
||||
|
||||
def _unique(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_id() -> str:
|
||||
return _unique("tenant")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def brand_id() -> str:
|
||||
return _unique("brand")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_brand_id() -> str:
|
||||
return _unique("brand")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def creator_id() -> str:
|
||||
return _unique("creator")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def influencer_id() -> str:
|
||||
return _unique("influencer")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def applicant_id() -> str:
|
||||
return _unique("applicant")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approver_id() -> str:
|
||||
return _unique("approver")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_url() -> str:
|
||||
return f"https://example.com/video-{uuid.uuid4().hex[:8]}.mp4"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forbidden_word() -> str:
|
||||
return f"测试违禁词-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def whitelist_term() -> str:
|
||||
return f"品牌专属词-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def competitor_name() -> str:
|
||||
return f"竞品-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
# ==================== 集成测试 Fixtures ====================
|
||||
# 使用 testcontainers 运行真实依赖,标记为 integration
|
||||
|
||||
|
||||
def _is_docker_available() -> bool:
|
||||
"""检查 Docker 是否可用"""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
# 在模块加载时检查一次 Docker 可用性
|
||||
_docker_available = None
|
||||
|
||||
|
||||
def docker_available() -> bool:
|
||||
"""获取 Docker 可用性(缓存结果)"""
|
||||
global _docker_available
|
||||
if _docker_available is None:
|
||||
_docker_available = _is_docker_available()
|
||||
return _docker_available
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_container():
|
||||
"""
|
||||
启动 PostgreSQL 容器(集成测试用)
|
||||
需要 Docker 运行
|
||||
|
||||
Yields:
|
||||
PostgresContainer: 容器实例
|
||||
"""
|
||||
pytest.importorskip("testcontainers")
|
||||
|
||||
if not docker_available():
|
||||
pytest.skip("Docker is not available")
|
||||
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
with PostgresContainer("postgres:15-alpine") as postgres:
|
||||
yield postgres
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def redis_container():
|
||||
"""
|
||||
启动 Redis 容器(集成测试用)
|
||||
需要 Docker 运行
|
||||
|
||||
Yields:
|
||||
RedisContainer: 容器实例
|
||||
"""
|
||||
pytest.importorskip("testcontainers")
|
||||
|
||||
if not docker_available():
|
||||
pytest.skip("Docker is not available")
|
||||
|
||||
from testcontainers.redis import RedisContainer
|
||||
|
||||
with RedisContainer("redis:7-alpine") as redis:
|
||||
yield redis
|
||||
|
||||
|
||||
# ==================== Mock 数据 Fixtures ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_response():
|
||||
"""
|
||||
AI 审核响应 mock 数据
|
||||
|
||||
Returns:
|
||||
dict: 模拟的 AI 审核结果
|
||||
"""
|
||||
return {
|
||||
"violations": [],
|
||||
"score": 95,
|
||||
"summary": "内容合规",
|
||||
"details": {
|
||||
"forbidden_words": [],
|
||||
"logo_detected": True,
|
||||
"duration_valid": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_violation_response():
|
||||
"""
|
||||
AI 审核违规响应 mock 数据
|
||||
|
||||
Returns:
|
||||
dict: 模拟的违规审核结果
|
||||
"""
|
||||
return {
|
||||
"violations": [
|
||||
{
|
||||
"type": "forbidden_word",
|
||||
"content": "最好",
|
||||
"position": {"start": 10, "end": 12},
|
||||
"severity": "medium",
|
||||
"suggestion": "建议删除或替换为其他词汇",
|
||||
}
|
||||
],
|
||||
"score": 65,
|
||||
"summary": "发现1处违规",
|
||||
"details": {
|
||||
"forbidden_words": ["最好"],
|
||||
"logo_detected": True,
|
||||
"duration_valid": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_video_metadata():
|
||||
"""
|
||||
示例视频元数据
|
||||
|
||||
Returns:
|
||||
dict: 视频元数据
|
||||
"""
|
||||
return {
|
||||
"id": "video-001",
|
||||
"title": "测试视频",
|
||||
"duration": 30,
|
||||
"resolution": "1080p",
|
||||
"creator_id": "creator-001",
|
||||
"platform": "douyin",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_task_data():
|
||||
"""
|
||||
示例审核任务数据
|
||||
|
||||
Returns:
|
||||
dict: 任务数据
|
||||
"""
|
||||
return {
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
"platform": "douyin",
|
||||
"creator_id": "creator-001",
|
||||
"priority": "normal",
|
||||
"rules": ["ad_law", "platform_rules"],
|
||||
}
|
||||
|
||||
|
||||
# ==================== AI 配置相关 Fixtures ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_models_response():
|
||||
"""Mock 模型列表响应"""
|
||||
return {
|
||||
"success": True,
|
||||
"models": {
|
||||
"text": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "claude-3-opus", "name": "Claude 3 Opus"},
|
||||
],
|
||||
"vision": [
|
||||
{"id": "gpt-4o", "name": "GPT-4o"},
|
||||
{"id": "qwen-vl-max", "name": "Qwen VL Max"},
|
||||
],
|
||||
"audio": [
|
||||
{"id": "whisper-1", "name": "Whisper"},
|
||||
{"id": "whisper-large-v3", "name": "Whisper Large V3"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection_test_success():
|
||||
"""Mock 连接测试成功响应"""
|
||||
return {
|
||||
"success": True,
|
||||
"results": {
|
||||
"text": {"success": True, "latency_ms": 342, "model": "gpt-4o"},
|
||||
"vision": {"success": True, "latency_ms": 528, "model": "gpt-4o"},
|
||||
"audio": {"success": True, "latency_ms": 215, "model": "whisper-1"},
|
||||
},
|
||||
"message": "所有模型连接成功",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection_test_partial_fail():
|
||||
"""Mock 连接测试部分失败响应"""
|
||||
return {
|
||||
"success": False,
|
||||
"results": {
|
||||
"text": {"success": True, "latency_ms": 342, "model": "gpt-4o"},
|
||||
"vision": {"success": True, "latency_ms": 528, "model": "gpt-4o"},
|
||||
"audio": {"success": False, "error": "Model not found", "model": "invalid-model"},
|
||||
},
|
||||
"message": "1 个模型连接失败,请检查模型名称或 API 权限",
|
||||
}
|
||||
|
||||
|
||||
# ==================== AI 客户端 Mock Fixtures ====================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ai_client():
|
||||
"""创建 Mock AI 客户端"""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock(return_value=MagicMock(
|
||||
content="[]",
|
||||
model="gpt-4o",
|
||||
usage={"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
||||
finish_reason="stop",
|
||||
))
|
||||
client.vision_analysis = AsyncMock(return_value=MagicMock(
|
||||
content="无竞品 Logo",
|
||||
model="gpt-4o",
|
||||
usage={"prompt_tokens": 200, "completion_tokens": 50, "total_tokens": 250},
|
||||
finish_reason="stop",
|
||||
))
|
||||
client.test_connection = AsyncMock(return_value=MagicMock(
|
||||
success=True,
|
||||
latency_ms=100,
|
||||
error=None,
|
||||
))
|
||||
client.close = AsyncMock()
|
||||
return client
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
AI 服务配置 API 测试 (TDD - 红色阶段)
|
||||
测试覆盖: 配置管理、模型列表、连通性测试
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.ai_config import (
|
||||
AIConfigResponse,
|
||||
ConnectionTestResponse,
|
||||
ModelsListResponse,
|
||||
)
|
||||
|
||||
|
||||
class TestGetAIConfig:
|
||||
"""获取 AI 配置"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_unconfigured_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||
"""未配置时返回 404"""
|
||||
response = await client.get(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""已配置时返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先创建配置
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers=headers,
|
||||
json={
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test-key-12345678",
|
||||
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
response = await client.get("/api/v1/ai-config", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_returns_masked_api_key(self, client: AsyncClient, tenant_id: str):
|
||||
"""API Key 应该脱敏"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先创建配置
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers=headers,
|
||||
json={
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test-key-12345678",
|
||||
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
response = await client.get("/api/v1/ai-config", headers=headers)
|
||||
data = response.json()
|
||||
parsed = AIConfigResponse.model_validate(data)
|
||||
|
||||
# API Key 应该脱敏,包含 ****
|
||||
assert "****" in parsed.api_key_masked
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_returns_models(self, client: AsyncClient, tenant_id: str):
|
||||
"""返回三个模型配置"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先创建配置
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers=headers,
|
||||
json={
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test-key-12345678",
|
||||
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
response = await client.get("/api/v1/ai-config", headers=headers)
|
||||
data = response.json()
|
||||
parsed = AIConfigResponse.model_validate(data)
|
||||
|
||||
assert parsed.models.text
|
||||
assert parsed.models.vision
|
||||
assert parsed.models.audio
|
||||
|
||||
|
||||
class TestUpdateAIConfig:
|
||||
"""更新 AI 配置"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""更新配置返回 200"""
|
||||
response = await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "oneapi",
|
||||
"base_url": "https://oneapi.example.com",
|
||||
"api_key": "sk-test-key-12345678",
|
||||
"models": {
|
||||
"text": "gpt-4o",
|
||||
"vision": "gpt-4o",
|
||||
"audio": "whisper-1",
|
||||
},
|
||||
"parameters": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_validates_provider(self, client: AsyncClient, tenant_id: str):
|
||||
"""校验提供商类型"""
|
||||
response = await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "invalid_provider",
|
||||
"base_url": "https://example.com",
|
||||
"api_key": "sk-test",
|
||||
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_validates_models_required(self, client: AsyncClient, tenant_id: str):
|
||||
"""三个模型都必填"""
|
||||
response = await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "oneapi",
|
||||
"base_url": "https://example.com",
|
||||
"api_key": "sk-test",
|
||||
"models": {"text": "gpt-4o"}, # 缺少 vision 和 audio
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_persists(self, client: AsyncClient, tenant_id: str):
|
||||
"""配置更新后可查询"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 更新
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers=headers,
|
||||
json={
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test-persist-12345678",
|
||||
"models": {
|
||||
"text": "gpt-4o-mini",
|
||||
"vision": "gpt-4o",
|
||||
"audio": "whisper-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# 查询
|
||||
response = await client.get("/api/v1/ai-config", headers=headers)
|
||||
data = response.json()
|
||||
parsed = AIConfigResponse.model_validate(data)
|
||||
|
||||
assert parsed.provider == "openai"
|
||||
assert parsed.models.text == "gpt-4o-mini"
|
||||
assert parsed.is_configured is True
|
||||
|
||||
|
||||
class TestGetModels:
|
||||
"""获取可用模型列表"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_models_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""获取模型列表返回 200"""
|
||||
response = await client.post(
|
||||
"/api/v1/ai-config/models",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "oneapi",
|
||||
"base_url": "https://oneapi.example.com",
|
||||
"api_key": "sk-test-key",
|
||||
},
|
||||
)
|
||||
# 可能返回 200(成功)或 502(连接失败)
|
||||
assert response.status_code in [200, 502]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_models_returns_categorized_list(self, client: AsyncClient, mock_ai_models_response):
|
||||
"""返回按类型分类的模型列表"""
|
||||
# 使用 mock 响应
|
||||
data = mock_ai_models_response
|
||||
parsed = ModelsListResponse.model_validate(data)
|
||||
|
||||
assert "text" in parsed.models
|
||||
assert "vision" in parsed.models
|
||||
assert "audio" in parsed.models
|
||||
assert isinstance(parsed.models["text"], list)
|
||||
|
||||
|
||||
class TestConnectionTest:
|
||||
"""连通性测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""测试连接返回 200"""
|
||||
response = await client.post(
|
||||
"/api/v1/ai-config/test",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "oneapi",
|
||||
"base_url": "https://oneapi.example.com",
|
||||
"api_key": "sk-test-key",
|
||||
"models": {
|
||||
"text": "gpt-4o",
|
||||
"vision": "gpt-4o",
|
||||
"audio": "whisper-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_returns_all_results(self, client: AsyncClient, tenant_id: str):
|
||||
"""返回三个模型的测试结果"""
|
||||
response = await client.post(
|
||||
"/api/v1/ai-config/test",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "oneapi",
|
||||
"base_url": "https://oneapi.example.com",
|
||||
"api_key": "sk-test-key",
|
||||
"models": {
|
||||
"text": "gpt-4o",
|
||||
"vision": "gpt-4o",
|
||||
"audio": "whisper-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ConnectionTestResponse.model_validate(data)
|
||||
|
||||
assert "text" in parsed.results
|
||||
assert "vision" in parsed.results
|
||||
assert "audio" in parsed.results
|
||||
assert isinstance(parsed.message, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_includes_latency(self, client: AsyncClient, mock_connection_test_success):
|
||||
"""成功时包含延迟信息"""
|
||||
data = mock_connection_test_success
|
||||
parsed = ConnectionTestResponse.model_validate(data)
|
||||
|
||||
for model_type, result in parsed.results.items():
|
||||
if result.success:
|
||||
assert result.latency_ms is not None
|
||||
assert result.latency_ms > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_includes_error_message(self, client: AsyncClient, mock_connection_test_partial_fail):
|
||||
"""失败时包含错误信息"""
|
||||
data = mock_connection_test_partial_fail
|
||||
parsed = ConnectionTestResponse.model_validate(data)
|
||||
|
||||
assert parsed.success is False
|
||||
# 至少有一个失败
|
||||
failed = [r for r in parsed.results.values() if not r.success]
|
||||
assert len(failed) > 0
|
||||
assert failed[0].error is not None
|
||||
|
||||
|
||||
class TestMultiTenantIsolation:
|
||||
"""多租户隔离"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_isolated_between_tenants(self, client: AsyncClient, tenant_id: str, other_brand_id: str):
|
||||
"""不同租户配置隔离"""
|
||||
# 为 tenant_id 配置
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-brand-a-key",
|
||||
"models": {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
|
||||
# 为 other_brand_id 配置
|
||||
await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": other_brand_id},
|
||||
json={
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://api.anthropic.com/v1",
|
||||
"api_key": "sk-brand-b-key",
|
||||
"models": {"text": "claude-3-opus", "vision": "claude-3-opus", "audio": "whisper-1"},
|
||||
},
|
||||
)
|
||||
|
||||
# 查询 tenant_id
|
||||
resp_a = await client.get("/api/v1/ai-config", headers={"X-Tenant-ID": tenant_id})
|
||||
data_a = resp_a.json()
|
||||
|
||||
# 查询 other_brand_id
|
||||
resp_b = await client.get("/api/v1/ai-config", headers={"X-Tenant-ID": other_brand_id})
|
||||
data_b = resp_b.json()
|
||||
|
||||
# 验证隔离
|
||||
assert data_a["provider"] == "openai"
|
||||
assert data_b["provider"] == "anthropic"
|
||||
|
||||
|
||||
class TestProviderSupport:
|
||||
"""提供商支持"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("provider", [
|
||||
"oneapi",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"deepseek",
|
||||
])
|
||||
async def test_supported_providers(self, client: AsyncClient, tenant_id: str, provider: str):
|
||||
"""支持的提供商类型"""
|
||||
response = await client.put(
|
||||
"/api/v1/ai-config",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"provider": provider,
|
||||
"base_url": f"https://api.{provider}.com/v1",
|
||||
"api_key": "sk-test-key",
|
||||
"models": {"text": "test-model", "vision": "test-model", "audio": "test-model"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
健康检查 API 测试
|
||||
测试覆盖: /health, /health/ready, /health/live
|
||||
使用依赖注入 mock 健康检查器
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""健康检查端点测试"""
|
||||
|
||||
# ==================== /health 测试 ====================
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_200(self, client: AsyncClient):
|
||||
"""健康检查返回 200 状态码"""
|
||||
response = await client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_response_structure(self, client: AsyncClient):
|
||||
"""健康检查返回正确的响应结构"""
|
||||
response = await client.get("/api/v1/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "service" in data
|
||||
assert "version" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_uses_settings(
|
||||
self, client: AsyncClient, app_settings: Settings
|
||||
):
|
||||
"""健康检查使用 settings 中的配置"""
|
||||
response = await client.get("/api/v1/health")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "healthy"
|
||||
# 使用 settings 中的值,而非硬编码
|
||||
assert data["service"] == app_settings.APP_NAME
|
||||
assert data["version"] == app_settings.APP_VERSION
|
||||
|
||||
# ==================== /health/ready 测试 ====================
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_returns_200(
|
||||
self, client: AsyncClient, mock_health_checker
|
||||
):
|
||||
"""就绪检查返回 200 状态码"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_ready_when_all_healthy(
|
||||
self, client: AsyncClient, mock_health_checker
|
||||
):
|
||||
"""所有依赖健康时返回 ready=true"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
assert data["ready"] is True
|
||||
assert data["checks"]["database"] is True
|
||||
assert data["checks"]["redis"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_not_ready_when_db_unhealthy(
|
||||
self, client: AsyncClient, mock_unhealthy_db_checker
|
||||
):
|
||||
"""数据库不健康时返回 ready=false"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
assert data["ready"] is False
|
||||
assert data["checks"]["database"] is False
|
||||
assert data["checks"]["redis"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_not_ready_when_redis_unhealthy(
|
||||
self, client: AsyncClient, mock_unhealthy_redis_checker
|
||||
):
|
||||
"""Redis 不健康时返回 ready=false"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
assert data["ready"] is False
|
||||
assert data["checks"]["database"] is True
|
||||
assert data["checks"]["redis"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_not_ready_when_all_unhealthy(
|
||||
self, client: AsyncClient, mock_all_unhealthy_checker
|
||||
):
|
||||
"""所有依赖不健康时返回 ready=false"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
assert data["ready"] is False
|
||||
assert data["checks"]["database"] is False
|
||||
assert data["checks"]["redis"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_check_returns_checks_detail(
|
||||
self, client: AsyncClient, mock_health_checker
|
||||
):
|
||||
"""就绪检查返回详细的检查结果"""
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
assert "checks" in data
|
||||
assert "database" in data["checks"]
|
||||
assert "redis" in data["checks"]
|
||||
|
||||
# ==================== /health/live 测试 ====================
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveness_check_returns_200(self, client: AsyncClient):
|
||||
"""存活检查返回 200 状态码"""
|
||||
response = await client.get("/api/v1/health/live")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveness_check_always_alive(self, client: AsyncClient):
|
||||
"""存活检查始终返回 alive=true(只检查进程存活)"""
|
||||
response = await client.get("/api/v1/health/live")
|
||||
data = response.json()
|
||||
|
||||
# liveness 不依赖外部服务,只要进程活着就返回 true
|
||||
assert data["alive"] is True
|
||||
|
||||
|
||||
class TestRootEndpoint:
|
||||
"""根路径测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_returns_200(self, client: AsyncClient):
|
||||
"""根路径返回 200 状态码"""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_response_structure(self, client: AsyncClient):
|
||||
"""根路径返回正确的响应结构"""
|
||||
response = await client.get("/")
|
||||
data = response.json()
|
||||
|
||||
assert "message" in data
|
||||
assert "version" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_uses_settings(
|
||||
self, client: AsyncClient, app_settings: Settings
|
||||
):
|
||||
"""根路径使用 settings 中的应用名称"""
|
||||
response = await client.get("/")
|
||||
data = response.json()
|
||||
|
||||
# 验证响应中包含 settings.APP_NAME
|
||||
assert app_settings.APP_NAME in data["message"]
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
健康检查 API 集成测试
|
||||
使用 testcontainers 运行真实 PostgreSQL 和 Redis
|
||||
运行: pytest tests/test_health_integration.py -m integration
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.main import app
|
||||
from app.services.health import get_health_checker, DefaultHealthChecker
|
||||
|
||||
|
||||
class RealHealthChecker:
|
||||
"""
|
||||
真实健康检查实现(用于集成测试)
|
||||
正确处理资源释放,支持连接超时配置
|
||||
"""
|
||||
|
||||
# 测试用短超时(秒),避免无效主机导致长时间等待
|
||||
DEFAULT_CONNECT_TIMEOUT = 2
|
||||
|
||||
def __init__(self, db_url: str, redis_url: str, connect_timeout: float = DEFAULT_CONNECT_TIMEOUT):
|
||||
self._db_url = db_url
|
||||
self._redis_url = redis_url
|
||||
self._connect_timeout = connect_timeout
|
||||
|
||||
async def check_database(self) -> bool:
|
||||
"""检查数据库连接(确保资源释放)"""
|
||||
engine = None
|
||||
try:
|
||||
engine = create_async_engine(
|
||||
self._db_url,
|
||||
connect_args={"timeout": self._connect_timeout}
|
||||
)
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
# 确保 engine 被正确释放
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
|
||||
async def check_redis(self) -> bool:
|
||||
"""检查 Redis 连接(确保资源释放)"""
|
||||
client = None
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
client = aioredis.from_url(
|
||||
self._redis_url,
|
||||
socket_connect_timeout=self._connect_timeout
|
||||
)
|
||||
await client.ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
# 确保 client 被正确释放
|
||||
if client is not None:
|
||||
try:
|
||||
await client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def check_all(self) -> dict[str, bool]:
|
||||
"""检查所有依赖"""
|
||||
return {
|
||||
"database": await self.check_database(),
|
||||
"redis": await self.check_redis(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestHealthCheckIntegration:
|
||||
"""健康检查集成测试(需要 Docker)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_with_real_postgres(self, postgres_container):
|
||||
"""使用真实 PostgreSQL 测试就绪检查"""
|
||||
# 获取容器连接信息
|
||||
host = postgres_container.get_container_host_ip()
|
||||
port = postgres_container.get_exposed_port(5432)
|
||||
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
|
||||
|
||||
# 创建真实健康检查器
|
||||
checker = RealHealthChecker(db_url=db_url, redis_url="redis://invalid:6379")
|
||||
|
||||
# 注入到 app
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
|
||||
try:
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
# 数据库应该健康
|
||||
assert data["checks"]["database"] is True
|
||||
# Redis 连接失败(无效地址)
|
||||
assert data["checks"]["redis"] is False
|
||||
# 整体不就绪
|
||||
assert data["ready"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_with_real_redis(self, redis_container):
|
||||
"""使用真实 Redis 测试就绪检查"""
|
||||
# 获取容器连接信息
|
||||
host = redis_container.get_container_host_ip()
|
||||
port = redis_container.get_exposed_port(6379)
|
||||
redis_url = f"redis://{host}:{port}"
|
||||
|
||||
# 创建真实健康检查器
|
||||
checker = RealHealthChecker(
|
||||
db_url="postgresql+asyncpg://invalid:invalid@invalid:5432/invalid",
|
||||
redis_url=redis_url
|
||||
)
|
||||
|
||||
# 注入到 app
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
|
||||
try:
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
# 数据库连接失败(无效地址)
|
||||
assert data["checks"]["database"] is False
|
||||
# Redis 应该健康
|
||||
assert data["checks"]["redis"] is True
|
||||
# 整体不就绪
|
||||
assert data["ready"] is False
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_with_all_real_deps(
|
||||
self, postgres_container, redis_container
|
||||
):
|
||||
"""使用真实 PostgreSQL 和 Redis 测试就绪检查"""
|
||||
# PostgreSQL 连接信息
|
||||
pg_host = postgres_container.get_container_host_ip()
|
||||
pg_port = postgres_container.get_exposed_port(5432)
|
||||
db_url = f"postgresql+asyncpg://test:test@{pg_host}:{pg_port}/test"
|
||||
|
||||
# Redis 连接信息
|
||||
redis_host = redis_container.get_container_host_ip()
|
||||
redis_port = redis_container.get_exposed_port(6379)
|
||||
redis_url = f"redis://{redis_host}:{redis_port}"
|
||||
|
||||
# 创建真实健康检查器
|
||||
checker = RealHealthChecker(db_url=db_url, redis_url=redis_url)
|
||||
|
||||
# 注入到 app
|
||||
app.dependency_overrides[get_health_checker] = lambda: checker
|
||||
|
||||
try:
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
data = response.json()
|
||||
|
||||
# 所有依赖应该健康
|
||||
assert data["checks"]["database"] is True
|
||||
assert data["checks"]["redis"] is True
|
||||
# 整体就绪
|
||||
assert data["ready"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDatabaseConnectionIntegration:
|
||||
"""数据库连接集成测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_query_execution(self, postgres_container):
|
||||
"""测试真实数据库查询执行"""
|
||||
host = postgres_container.get_container_host_ip()
|
||||
port = postgres_container.get_exposed_port(5432)
|
||||
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text("SELECT 1 as value"))
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == 1
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection_failure(self):
|
||||
"""测试数据库连接失败场景"""
|
||||
invalid_url = "postgresql+asyncpg://invalid:invalid@invalid:5432/invalid"
|
||||
checker = RealHealthChecker(db_url=invalid_url, redis_url="redis://invalid:6379")
|
||||
|
||||
result = await checker.check_database()
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDefaultHealthCheckerIntegration:
|
||||
"""DefaultHealthChecker 集成测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_checker_with_real_postgres(self, postgres_container):
|
||||
"""测试 DefaultHealthChecker 使用真实 PostgreSQL"""
|
||||
host = postgres_container.get_container_host_ip()
|
||||
port = postgres_container.get_exposed_port(5432)
|
||||
db_url = f"postgresql+asyncpg://test:test@{host}:{port}/test"
|
||||
|
||||
engine = create_async_engine(db_url)
|
||||
try:
|
||||
# 使用短超时避免无效主机长时间等待
|
||||
checker = DefaultHealthChecker(
|
||||
db_engine=engine,
|
||||
redis_url="redis://invalid:6379",
|
||||
connect_timeout=2
|
||||
)
|
||||
result = await checker.check_database()
|
||||
assert result is True
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_checker_with_real_redis(self, redis_container):
|
||||
"""测试 DefaultHealthChecker 使用真实 Redis"""
|
||||
host = redis_container.get_container_host_ip()
|
||||
port = redis_container.get_exposed_port(6379)
|
||||
redis_url = f"redis://{host}:{port}"
|
||||
|
||||
checker = DefaultHealthChecker(db_engine=None, redis_url=redis_url)
|
||||
result = await checker.check_redis()
|
||||
assert result is True
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
一致性指标 API 测试 (TDD - 红色阶段)
|
||||
双轨制: Rolling 30 Days + Snapshot 周/月
|
||||
维度: Influencer + Rule Type
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import ConsistencyMetricsResponse, ConsistencyWindow, ViolationType
|
||||
|
||||
|
||||
class TestConsistencyMetrics:
|
||||
"""一致性指标查询"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_influencer_id(self, client: AsyncClient):
|
||||
"""缺少 influencer_id 返回 422"""
|
||||
response = await client.get("/api/v1/metrics/consistency?window=rolling_30d")
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rolling_30d_returns_metrics(self, client: AsyncClient, influencer_id: str):
|
||||
"""Rolling 30 Days 返回指标"""
|
||||
response = await client.get(
|
||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=rolling_30d"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
||||
assert parsed.influencer_id == influencer_id
|
||||
assert parsed.window == ConsistencyWindow.ROLLING_30D
|
||||
assert parsed.period_start < parsed.period_end
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_week_returns_metrics(self, client: AsyncClient, influencer_id: str):
|
||||
"""Snapshot 周度返回指标"""
|
||||
response = await client.get(
|
||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=snapshot_week"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
||||
assert parsed.window == ConsistencyWindow.SNAPSHOT_WEEK
|
||||
assert parsed.period_start < parsed.period_end
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_rule_type(self, client: AsyncClient, influencer_id: str):
|
||||
"""按规则类型筛选"""
|
||||
response = await client.get(
|
||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}"
|
||||
"&window=rolling_30d&rule_type=forbidden_word"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
||||
if parsed.metrics:
|
||||
assert all(m.rule_type == ViolationType.FORBIDDEN_WORD for m in parsed.metrics)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_window_returns_422(self, client: AsyncClient, influencer_id: str):
|
||||
"""非法窗口返回 422"""
|
||||
response = await client.get(
|
||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=invalid_window"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
特例审批超时策略测试 (TDD - 红色阶段)
|
||||
默认行为: 48 小时超时自动拒绝 + 必须留痕
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.review import RiskExceptionRecord, RiskExceptionStatus, RiskTargetType
|
||||
from app.services.risk_exception import apply_timeout_policy
|
||||
|
||||
|
||||
class TestRiskExceptionTimeout:
|
||||
"""超时自动拒绝"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_reject_after_48_hours(self):
|
||||
"""超过 48 小时自动拒绝并记录原因"""
|
||||
now = datetime.now(timezone.utc)
|
||||
record = RiskExceptionRecord(
|
||||
record_id="rec-001",
|
||||
applicant_id="applicant-001",
|
||||
apply_time=now - timedelta(hours=49),
|
||||
target_type=RiskTargetType.INFLUENCER,
|
||||
target_id="influencer-001",
|
||||
risk_rule_id="rule-absolute-word",
|
||||
status=RiskExceptionStatus.PENDING,
|
||||
valid_start_time=now - timedelta(days=1),
|
||||
valid_end_time=now + timedelta(days=3),
|
||||
reason_category="业务强需",
|
||||
justification="临时投放",
|
||||
attachment_url=None,
|
||||
current_approver_id="approver-001",
|
||||
approval_chain_log=[],
|
||||
auto_rejected=False,
|
||||
rejection_reason=None,
|
||||
last_status_at=None,
|
||||
)
|
||||
|
||||
updated = apply_timeout_policy(record, now)
|
||||
assert updated.status == RiskExceptionStatus.REJECTED
|
||||
assert updated.auto_rejected is True
|
||||
assert updated.rejection_reason == "timeout"
|
||||
assert updated.last_status_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_auto_reject_within_48_hours(self):
|
||||
"""未超时不应自动拒绝"""
|
||||
now = datetime.now(timezone.utc)
|
||||
record = RiskExceptionRecord(
|
||||
record_id="rec-002",
|
||||
applicant_id="applicant-002",
|
||||
apply_time=now - timedelta(hours=24),
|
||||
target_type=RiskTargetType.CONTENT,
|
||||
target_id="content-001",
|
||||
risk_rule_id="rule-soft-risk",
|
||||
status=RiskExceptionStatus.PENDING,
|
||||
valid_start_time=now - timedelta(days=1),
|
||||
valid_end_time=now + timedelta(days=1),
|
||||
reason_category="误判",
|
||||
justification="内容无违规",
|
||||
attachment_url=None,
|
||||
current_approver_id="approver-002",
|
||||
approval_chain_log=[],
|
||||
auto_rejected=False,
|
||||
rejection_reason=None,
|
||||
last_status_at=None,
|
||||
)
|
||||
|
||||
updated = apply_timeout_policy(record, now)
|
||||
assert updated.status == RiskExceptionStatus.PENDING
|
||||
assert updated.auto_rejected is False
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
特例审批 API 测试 (TDD - 红色阶段)
|
||||
要求: 48 小时超时自动拒绝 + 必须留痕
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import (
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestRiskExceptionCRUD:
|
||||
"""特例记录基础流程"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_exception_returns_201(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
|
||||
"""创建特例返回 201"""
|
||||
now = datetime.now(timezone.utc)
|
||||
response = await client.post(
|
||||
"/api/v1/risk-exceptions",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"applicant_id": applicant_id,
|
||||
"target_type": "influencer",
|
||||
"target_id": "influencer-001",
|
||||
"risk_rule_id": "rule-absolute-word",
|
||||
"reason_category": "业务强需",
|
||||
"justification": "业务需要短期投放",
|
||||
"attachment_url": "https://example.com/attach.png",
|
||||
"current_approver_id": approver_id,
|
||||
"valid_start_time": now.isoformat(),
|
||||
"valid_end_time": (now + timedelta(days=7)).isoformat(),
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
parsed = RiskExceptionRecord.model_validate(response.json())
|
||||
assert parsed.status == RiskExceptionStatus.PENDING
|
||||
assert parsed.current_approver_id == approver_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_exception_returns_200(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
|
||||
"""查询特例记录返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
now = datetime.now(timezone.utc)
|
||||
create_resp = await client.post(
|
||||
"/api/v1/risk-exceptions",
|
||||
headers=headers,
|
||||
json={
|
||||
"applicant_id": applicant_id,
|
||||
"target_type": "content",
|
||||
"target_id": "content-001",
|
||||
"risk_rule_id": "rule-soft-risk",
|
||||
"reason_category": "误判",
|
||||
"justification": "内容无违规",
|
||||
"current_approver_id": approver_id,
|
||||
"valid_start_time": now.isoformat(),
|
||||
"valid_end_time": (now + timedelta(days=3)).isoformat(),
|
||||
}
|
||||
)
|
||||
record_id = create_resp.json()["record_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/risk-exceptions/{record_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = RiskExceptionRecord.model_validate(response.json())
|
||||
assert parsed.record_id == record_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_exception_updates_status(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
|
||||
"""审批通过后状态更新为 approved"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
now = datetime.now(timezone.utc)
|
||||
create_resp = await client.post(
|
||||
"/api/v1/risk-exceptions",
|
||||
headers=headers,
|
||||
json={
|
||||
"applicant_id": applicant_id,
|
||||
"target_type": "order",
|
||||
"target_id": "order-001",
|
||||
"risk_rule_id": "rule-competitor",
|
||||
"reason_category": "测试豁免",
|
||||
"justification": "测试流程",
|
||||
"current_approver_id": approver_id,
|
||||
"valid_start_time": now.isoformat(),
|
||||
"valid_end_time": (now + timedelta(days=1)).isoformat(),
|
||||
}
|
||||
)
|
||||
record_id = create_resp.json()["record_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/risk-exceptions/{record_id}/approve",
|
||||
headers=headers,
|
||||
json={
|
||||
"approver_id": approver_id,
|
||||
"comment": "同意",
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = RiskExceptionRecord.model_validate(response.json())
|
||||
assert parsed.status == RiskExceptionStatus.APPROVED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_exception_requires_reason(self, client: AsyncClient, tenant_id: str, applicant_id: str, approver_id: str):
|
||||
"""驳回时需要理由"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
now = datetime.now(timezone.utc)
|
||||
create_resp = await client.post(
|
||||
"/api/v1/risk-exceptions",
|
||||
headers=headers,
|
||||
json={
|
||||
"applicant_id": applicant_id,
|
||||
"target_type": "influencer",
|
||||
"target_id": "influencer-002",
|
||||
"risk_rule_id": "rule-absolute-word",
|
||||
"reason_category": "业务强需",
|
||||
"justification": "需要豁免",
|
||||
"current_approver_id": approver_id,
|
||||
"valid_start_time": now.isoformat(),
|
||||
"valid_end_time": (now + timedelta(days=2)).isoformat(),
|
||||
}
|
||||
)
|
||||
record_id = create_resp.json()["record_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/risk-exceptions/{record_id}/reject",
|
||||
headers=headers,
|
||||
json={
|
||||
"approver_id": approver_id,
|
||||
"comment": "",
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
规则管理 API 测试 (TDD - 红色阶段)
|
||||
测试覆盖: 违禁词库、白名单、竞品库、平台规则
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import ScriptReviewResponse, ViolationType
|
||||
|
||||
|
||||
class TestForbiddenWords:
|
||||
"""违禁词库管理"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_forbidden_words_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询违禁词列表返回 200"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_forbidden_words_returns_array(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询违禁词返回数组"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert "items" in data
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forbidden_word_has_category(self, client: AsyncClient, tenant_id: str):
|
||||
"""违禁词包含分类信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
if data["items"]:
|
||||
word = data["items"][0]
|
||||
assert "category" in word # 极限词、功效词、敏感词等
|
||||
assert "word" in word
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_forbidden_word_returns_201(self, client: AsyncClient, tenant_id: str, forbidden_word: str):
|
||||
"""添加违禁词返回 201"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"word": forbidden_word,
|
||||
"category": "custom",
|
||||
"severity": "medium",
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data.get("id")
|
||||
assert data.get("word") == forbidden_word
|
||||
assert data.get("category") == "custom"
|
||||
assert data.get("severity") == "medium"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_duplicate_word_returns_409(self, client: AsyncClient, tenant_id: str, forbidden_word: str):
|
||||
"""添加重复违禁词返回 409"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先添加一次
|
||||
await client.post(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers=headers,
|
||||
json={"word": forbidden_word, "category": "custom", "severity": "medium"}
|
||||
)
|
||||
# 再次添加
|
||||
response = await client.post(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers=headers,
|
||||
json={"word": forbidden_word, "category": "custom", "severity": "medium"}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_forbidden_word_returns_204(self, client: AsyncClient, tenant_id: str, forbidden_word: str):
|
||||
"""删除违禁词返回 204"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先添加
|
||||
create_resp = await client.post(
|
||||
"/api/v1/rules/forbidden-words",
|
||||
headers=headers,
|
||||
json={"word": forbidden_word, "category": "custom", "severity": "low"}
|
||||
)
|
||||
word_id = create_resp.json()["id"]
|
||||
|
||||
# 删除
|
||||
response = await client.delete(
|
||||
f"/api/v1/rules/forbidden-words/{word_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_category(self, client: AsyncClient, tenant_id: str):
|
||||
"""按分类筛选违禁词"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/forbidden-words?category=absolute",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestWhitelist:
|
||||
"""白名单管理"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_whitelist_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询白名单返回 200"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/whitelist",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_whitelist_returns_201(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str):
|
||||
"""添加白名单返回 201"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/whitelist",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"term": whitelist_term,
|
||||
"reason": "品牌方授权使用",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data.get("id")
|
||||
assert data.get("term") == whitelist_term
|
||||
assert data.get("brand_id") == brand_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitelist_overrides_forbidden(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str):
|
||||
"""白名单覆盖违禁词检测"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先添加到白名单
|
||||
await client.post(
|
||||
"/api/v1/rules/whitelist",
|
||||
headers=headers,
|
||||
json={
|
||||
"term": whitelist_term,
|
||||
"reason": "品牌 slogan",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
|
||||
# 提交包含该词的脚本
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"content": f"我们是您的{whitelist_term}",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
flagged_words = [
|
||||
v.content for v in parsed.violations
|
||||
if v.type == ViolationType.FORBIDDEN_WORD
|
||||
]
|
||||
assert whitelist_term not in flagged_words
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitelist_scoped_to_brand(self, client: AsyncClient, tenant_id: str, whitelist_term: str, brand_id: str, other_brand_id: str):
|
||||
"""白名单仅对指定品牌生效"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 为 brand-001 添加白名单
|
||||
await client.post(
|
||||
"/api/v1/rules/whitelist",
|
||||
headers=headers,
|
||||
json={
|
||||
"term": whitelist_term,
|
||||
"reason": "品牌方授权",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
|
||||
# 其他品牌提交应该仍被标记
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"content": f"这是{whitelist_term}",
|
||||
"platform": "douyin",
|
||||
"brand_id": other_brand_id, # 不同品牌
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert len(parsed.violations) > 0 or parsed.score < 100
|
||||
|
||||
|
||||
class TestCompetitorList:
|
||||
"""竞品库管理"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_competitors_returns_200(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""查询竞品列表返回 200"""
|
||||
response = await client.get(
|
||||
f"/api/v1/rules/competitors?brand_id={brand_id}",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_competitor_returns_201(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str):
|
||||
"""添加竞品返回 201"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/competitors",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"name": competitor_name,
|
||||
"brand_id": brand_id,
|
||||
"logo_url": "https://example.com/competitor-logo.png",
|
||||
"keywords": [competitor_name],
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data.get("id")
|
||||
assert data.get("name") == competitor_name
|
||||
assert data.get("brand_id") == brand_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_competitor_has_logo(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str):
|
||||
"""竞品包含 Logo 信息(用于视觉检测)"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
await client.post(
|
||||
"/api/v1/rules/competitors",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": competitor_name,
|
||||
"brand_id": brand_id,
|
||||
"logo_url": "https://example.com/logo-b.png",
|
||||
"keywords": [competitor_name],
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/rules/competitors?brand_id={brand_id}",
|
||||
headers=headers,
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
competitors = data.get("items", [])
|
||||
target = next((c for c in competitors if c.get("name") == competitor_name), None)
|
||||
assert target is not None
|
||||
assert target.get("logo_url")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_competitor_returns_204(self, client: AsyncClient, tenant_id: str, competitor_name: str, brand_id: str):
|
||||
"""删除竞品返回 204"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/rules/competitors",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": competitor_name,
|
||||
"brand_id": brand_id,
|
||||
"keywords": [competitor_name],
|
||||
}
|
||||
)
|
||||
competitor_id = create_resp.json()["id"]
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/rules/competitors/{competitor_id}",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
class TestPlatformRules:
|
||||
"""平台规则管理"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_platform_rules_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询平台规则返回 200"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/platforms",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_platform_rules_by_name(self, client: AsyncClient, tenant_id: str):
|
||||
"""按平台名称查询规则"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/platforms/douyin",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["platform"] == "douyin"
|
||||
assert "rules" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_rules_have_version(self, client: AsyncClient, tenant_id: str):
|
||||
"""平台规则包含版本信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/platforms/douyin",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert "version" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supported_platforms(self, client: AsyncClient, tenant_id: str):
|
||||
"""支持的平台列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/rules/platforms",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
platforms = [p["platform"] for p in data["items"]]
|
||||
assert "douyin" in platforms
|
||||
assert "xiaohongshu" in platforms
|
||||
assert "bilibili" in platforms
|
||||
|
||||
|
||||
class TestRuleConflictDetection:
|
||||
"""规则冲突检测"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"required_phrases": ["绝对有效"], # 可能违反平台规则
|
||||
}
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "conflicts" in data
|
||||
assert isinstance(data["conflicts"], list)
|
||||
assert len(data["conflicts"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_includes_details(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""冲突检测包含详细信息"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"required_phrases": ["最好的产品"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
assert data.get("conflicts")
|
||||
conflict = data["conflicts"][0]
|
||||
assert "brief_rule" in conflict
|
||||
assert "platform_rule" in conflict
|
||||
assert "suggestion" in conflict
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
脚本预审 API 测试 (TDD - 红色阶段)
|
||||
测试覆盖: 脚本提交、违规检测、语境理解
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import ScriptReviewResponse, ViolationType, SoftRiskAction
|
||||
|
||||
|
||||
class TestSubmitScript:
|
||||
"""提交脚本预审"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_script_returns_200(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""提交脚本返回 200"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这是一段测试脚本内容",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_script_returns_review_result(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""提交脚本返回审核结果"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这是一段测试脚本内容",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert isinstance(parsed.summary, str) and parsed.summary
|
||||
assert 0 <= parsed.score <= 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_empty_script_returns_422(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""提交空脚本返回 422"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestForbiddenWordDetection:
|
||||
"""违禁词检测"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_absolute_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""检测广告极限词:最好、第一"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "我们的产品是全网最好的,销量第一",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert len(parsed.violations) > 0
|
||||
violation_types = [v.type for v in parsed.violations]
|
||||
assert ViolationType.FORBIDDEN_WORD in violation_types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_efficacy_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""检测功效词:根治、治愈"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "使用我们的产品可以根治失眠问题",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
violation_types = [v.type for v in parsed.violations]
|
||||
assert ViolationType.EFFICACY_CLAIM in violation_types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_violation_position(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""返回违规词位置"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这是最好的产品", # "最好"是违禁词
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||||
violation = parsed.violations[0]
|
||||
assert violation.position is not None
|
||||
assert violation.position.start >= 0
|
||||
assert violation.position.end > violation.position.start
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_violation_suggestion(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""每个违规项包含修改建议"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这是最好的产品", # "最好"是违禁词
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||||
assert isinstance(parsed.violations[0].suggestion, str)
|
||||
assert parsed.violations[0].suggestion
|
||||
|
||||
|
||||
class TestContextUnderstanding:
|
||||
"""语境理解(降低误报)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_ad_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""非广告语境不应标记为违规:最开心的一天"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "今天是我最开心的一天,因为见到了老朋友",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
forbidden_violations = [
|
||||
v for v in parsed.violations
|
||||
if v.type == ViolationType.FORBIDDEN_WORD and "最" in v.content
|
||||
]
|
||||
assert len(forbidden_violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_story_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""故事情节语境不应标记:他是第一个到达的人"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "他是第一个到达终点的人,大家都为他鼓掌",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
forbidden_violations = [
|
||||
v for v in parsed.violations
|
||||
if v.type == ViolationType.FORBIDDEN_WORD and "第一" in v.content
|
||||
]
|
||||
assert len(forbidden_violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ad_context_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""广告语境应标记:我们的产品第一"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "我们的产品销量第一,品质最好",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert len(parsed.violations) > 0
|
||||
|
||||
|
||||
class TestSellingPointCheck:
|
||||
"""卖点遗漏检查"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_missing_selling_points(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""检查是否遗漏必要卖点"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这个产品很好用",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["功效说明", "使用方法", "品牌名称"],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert parsed.missing_points is not None
|
||||
assert isinstance(parsed.missing_points, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""所有卖点都覆盖时返回空"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["品牌名称", "使用方法", "功效说明"],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert parsed.missing_points == []
|
||||
|
||||
|
||||
class TestScoreCalculation:
|
||||
"""合规分数计算"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_content_returns_high_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""合规内容返回高分(>=90)"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "今天给大家分享一个护肤小技巧,记得每天早晚洁面哦",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert parsed.score >= 90
|
||||
high_risk = [v for v in parsed.violations if v.severity.value == "high"]
|
||||
assert len(high_risk) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_violation_content_returns_low_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""违规内容返回低分(<80)"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "这是最好的产品,可以根治所有问题,效果第一",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert parsed.score < 80
|
||||
assert len(parsed.violations) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_range_valid(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""分数在有效范围内 0-100"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "任意内容",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
assert 0 <= parsed.score <= 100
|
||||
|
||||
|
||||
class TestSoftRiskWarnings:
|
||||
"""软性风控提示"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_threshold_returns_warning(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""临界值接近阈值时返回软性提示(不阻断)"""
|
||||
response = await client.post(
|
||||
"/api/v1/scripts/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"content": "内容正常但指标接近阈值",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"soft_risk_context": {
|
||||
"violation_rate": 0.045,
|
||||
"violation_threshold": 0.05,
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = ScriptReviewResponse.model_validate(data)
|
||||
|
||||
matched = [
|
||||
w for w in parsed.soft_warnings
|
||||
if w.code == "NEAR_THRESHOLD" and w.action_required == SoftRiskAction.CONFIRM
|
||||
]
|
||||
assert matched, "应返回临界值软性提示"
|
||||
assert all(w.blocking is False for w in matched)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
软性风控逻辑测试 (TDD - 红色阶段)
|
||||
触发条件: 临界值、低置信度、历史记录
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.schemas.review import SoftRiskContext, SoftRiskAction
|
||||
from app.services.soft_risk import evaluate_soft_risk
|
||||
|
||||
|
||||
class TestSoftRiskEvaluator:
|
||||
"""软性风控判定"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_threshold_warns(self):
|
||||
"""临界值接近阈值触发二次确认提示"""
|
||||
context = SoftRiskContext(
|
||||
violation_rate=0.045,
|
||||
violation_threshold=0.05,
|
||||
)
|
||||
warnings = evaluate_soft_risk(context)
|
||||
matched = [
|
||||
w for w in warnings
|
||||
if w.code == "NEAR_THRESHOLD" and w.action_required == SoftRiskAction.CONFIRM
|
||||
]
|
||||
assert matched
|
||||
assert all(w.blocking is False for w in matched)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_confidence_warns(self):
|
||||
"""ASR/OCR 置信度处于 60%-80% 触发备注提示"""
|
||||
context = SoftRiskContext(
|
||||
asr_confidence=0.7,
|
||||
ocr_confidence=0.65,
|
||||
)
|
||||
warnings = evaluate_soft_risk(context)
|
||||
codes = {w.code for w in warnings}
|
||||
assert "LOW_CONFIDENCE_ASR" in codes or "LOW_CONFIDENCE_OCR" in codes
|
||||
assert all(w.action_required == SoftRiskAction.NOTE for w in warnings if "LOW_CONFIDENCE" in w.code)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_violation_warns(self):
|
||||
"""历史记录存在类似违规触发备注提示"""
|
||||
context = SoftRiskContext(
|
||||
has_history_violation=True,
|
||||
)
|
||||
warnings = evaluate_soft_risk(context)
|
||||
matched = [w for w in warnings if w.code == "HISTORY_RISK"]
|
||||
assert matched
|
||||
assert all(w.action_required == SoftRiskAction.NOTE for w in matched)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_context_returns_empty(self):
|
||||
"""安全场景无软性提示"""
|
||||
context = SoftRiskContext(
|
||||
violation_rate=0.01,
|
||||
violation_threshold=0.05,
|
||||
asr_confidence=0.95,
|
||||
ocr_confidence=0.92,
|
||||
has_history_violation=False,
|
||||
)
|
||||
warnings = evaluate_soft_risk(context)
|
||||
assert warnings == []
|
||||
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
审核任务 API 测试 (TDD - 红色阶段)
|
||||
测试覆盖: 创建任务、查询任务、更新任务状态
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import TaskResponse, TaskListResponse, TaskStatus
|
||||
|
||||
|
||||
class TestCreateTask:
|
||||
"""创建审核任务"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_returns_201(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""创建任务返回 201"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
"video_url": video_url,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_returns_task_id(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""创建任务返回任务 ID"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
"video_url": video_url,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.task_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_initial_status_pending(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""创建任务初始状态为 pending"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
"video_url": video_url,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.status == TaskStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_validates_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""创建任务校验平台参数"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "invalid_platform",
|
||||
"creator_id": creator_id,
|
||||
"video_url": video_url,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_validates_video_url(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
||||
"""创建任务校验视频 URL"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": "not-a-url",
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_allows_missing_video(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
||||
"""创建任务允许暂不上传视频"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.has_video is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_with_script_content(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
||||
"""创建任务可携带脚本内容"""
|
||||
response = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
"script_content": "脚本内容示例",
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.has_script is True
|
||||
assert parsed.script_content == "脚本内容示例"
|
||||
|
||||
|
||||
class TestGetTask:
|
||||
"""查询审核任务"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""查询存在的任务返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先创建任务
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
"video_url": video_url,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
# 查询任务
|
||||
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_returns_task_details(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""查询任务返回完整信息"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
|
||||
assert parsed.task_id == task_id
|
||||
assert parsed.video_url == video_url
|
||||
assert parsed.platform.value == "douyin"
|
||||
assert parsed.creator_id == creator_id
|
||||
assert parsed.has_video is True
|
||||
assert parsed.created_at
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_task_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询不存在的任务返回 404"""
|
||||
response = await client.get(
|
||||
"/api/v1/tasks/nonexistent-task-id",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestListTasks:
|
||||
"""任务列表查询"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询任务列表返回 200"""
|
||||
response = await client.get(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_returns_array(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询任务列表返回数组"""
|
||||
response = await client.get(
|
||||
"/api/v1/tasks",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskListResponse.model_validate(data)
|
||||
assert isinstance(parsed.items, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_pagination(self, client: AsyncClient, tenant_id: str):
|
||||
"""任务列表支持分页"""
|
||||
response = await client.get(
|
||||
"/api/v1/tasks?page=1&page_size=10",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskListResponse.model_validate(data)
|
||||
assert parsed.page == 1
|
||||
assert parsed.page_size == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_filter_by_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""任务列表支持按状态筛选"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
response = await client.get("/api/v1/tasks?status=pending", headers=headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
parsed = TaskListResponse.model_validate(data)
|
||||
assert any(item.task_id == task_id for item in parsed.items)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_filter_by_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""任务列表支持按平台筛选"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
response = await client.get("/api/v1/tasks?platform=douyin", headers=headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
parsed = TaskListResponse.model_validate(data)
|
||||
assert any(item.task_id == task_id for item in parsed.items)
|
||||
|
||||
|
||||
class TestUploadTaskAssets:
|
||||
"""任务脚本/视频上传"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_script_requires_payload(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
||||
"""上传脚本必须提供内容或文件 URL"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/script",
|
||||
headers=headers,
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_script_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
||||
"""上传脚本更新任务内容"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/script",
|
||||
headers=headers,
|
||||
json={"script_content": "更新后的脚本"},
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.has_script is True
|
||||
assert parsed.script_content == "更新后的脚本"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_video_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str, video_url: str):
|
||||
"""上传视频更新任务视频 URL"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/video",
|
||||
headers=headers,
|
||||
json={"video_url": video_url},
|
||||
)
|
||||
data = response.json()
|
||||
parsed = TaskResponse.model_validate(data)
|
||||
assert parsed.has_video is True
|
||||
assert parsed.video_url == video_url
|
||||
|
||||
|
||||
class TestUpdateTaskStatus:
|
||||
"""更新任务状态"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""通过任务返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 创建任务
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
# 通过任务
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/approve",
|
||||
headers=headers,
|
||||
json={"comment": "审核通过"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_task_updates_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""通过任务更新状态为 approved"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/tasks/{task_id}/approve",
|
||||
headers=headers,
|
||||
json={"comment": "审核通过"}
|
||||
)
|
||||
|
||||
# 验证状态
|
||||
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
parsed = TaskResponse.model_validate(get_resp.json())
|
||||
assert parsed.status == TaskStatus.APPROVED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""驳回任务返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/reject",
|
||||
headers=headers,
|
||||
json={"reason": "违规内容", "violations": ["forbidden_word"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
||||
parsed = TaskResponse.model_validate(get_resp.json())
|
||||
assert parsed.status == TaskStatus.REJECTED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_task_requires_reason(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
||||
"""驳回任务必须提供原因"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
create_resp = await client.post(
|
||||
"/api/v1/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
task_id = create_resp.json()["task_id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tasks/{task_id}/reject",
|
||||
headers=headers,
|
||||
json={}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
视频审核 API 测试 (TDD - 红色阶段)
|
||||
测试覆盖: 视频上传、异步审核、审核结果、进度查询
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.schemas.review import (
|
||||
VideoReviewSubmitResponse,
|
||||
VideoReviewProgressResponse,
|
||||
VideoReviewResultResponse,
|
||||
TaskStatus,
|
||||
RiskLevel,
|
||||
ViolationType,
|
||||
)
|
||||
|
||||
|
||||
class TestVideoUpload:
|
||||
"""视频上传"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_video_url_returns_202(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""提交视频 URL 返回 202 Accepted(异步处理)"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_video_returns_review_id(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""提交视频返回审核任务 ID"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
parsed = VideoReviewSubmitResponse.model_validate(data)
|
||||
assert parsed.review_id
|
||||
assert parsed.status == TaskStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_video_validates_url(self, client: AsyncClient, tenant_id: str, brand_id: str, creator_id: str):
|
||||
"""校验视频 URL 格式"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": "invalid-url",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_video_validates_platform(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""校验投放平台"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "invalid_platform",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestReviewProgress:
|
||||
"""审核进度查询"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""查询进度返回 200"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
# 先提交视频
|
||||
submit_resp = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
review_id = submit_resp.json()["review_id"]
|
||||
|
||||
# 查询进度
|
||||
response = await client.get(
|
||||
f"/api/v1/videos/review/{review_id}/progress",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_returns_status(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""查询进度返回状态信息"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
submit_resp = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
review_id = submit_resp.json()["review_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/videos/review/{review_id}/progress",
|
||||
headers=headers,
|
||||
)
|
||||
data = response.json()
|
||||
parsed = VideoReviewProgressResponse.model_validate(data)
|
||||
|
||||
assert parsed.review_id == review_id
|
||||
assert parsed.status in [TaskStatus.PENDING, TaskStatus.PROCESSING]
|
||||
assert 0 <= parsed.progress <= 100
|
||||
assert isinstance(parsed.current_step, str) and parsed.current_step
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_shows_current_step(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""进度显示当前处理步骤"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
submit_resp = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
review_id = submit_resp.json()["review_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/videos/review/{review_id}/progress",
|
||||
headers=headers,
|
||||
)
|
||||
data = response.json()
|
||||
parsed = VideoReviewProgressResponse.model_validate(data)
|
||||
|
||||
assert isinstance(parsed.current_step, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询不存在的审核任务返回 404"""
|
||||
response = await client.get(
|
||||
"/api/v1/videos/review/nonexistent-id/progress",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestReviewResult:
|
||||
"""审核结果查询"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_result_processing_returns_202(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""查询处理中的审核返回 202 并返回进度结构"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
submit_resp = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers=headers,
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
review_id = submit_resp.json()["review_id"]
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/videos/review/{review_id}/result",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewProgressResponse.model_validate(response.json())
|
||||
assert parsed.review_id == review_id
|
||||
assert parsed.status in [TaskStatus.PENDING, TaskStatus.PROCESSING]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_result_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||
"""查询不存在的审核任务返回 404"""
|
||||
response = await client.get(
|
||||
"/api/v1/videos/review/nonexistent-id/result",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestViolationStructure:
|
||||
"""违规项结构验证(使用 Mock 数据)"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_completed_review(self):
|
||||
"""Mock 已完成的审核结果"""
|
||||
return {
|
||||
"review_id": "test-review-001",
|
||||
"status": "completed",
|
||||
"score": 65,
|
||||
"summary": "发现 2 处违规",
|
||||
"violations": [
|
||||
{
|
||||
"type": "forbidden_word",
|
||||
"content": "最好",
|
||||
"timestamp": 15,
|
||||
"timestamp_end": 17,
|
||||
"severity": "high",
|
||||
"source": "speech",
|
||||
"suggestion": "建议删除或替换",
|
||||
},
|
||||
{
|
||||
"type": "competitor_logo",
|
||||
"content": "竞品A",
|
||||
"timestamp": 45,
|
||||
"timestamp_end": 48,
|
||||
"severity": "high",
|
||||
"source": "visual",
|
||||
"suggestion": "请移除画面中的竞品露出",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_violation_has_timestamp(self, mock_completed_review):
|
||||
"""违规项包含时间戳"""
|
||||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||||
for violation in parsed.violations:
|
||||
assert violation.timestamp is not None
|
||||
assert violation.timestamp_end is not None
|
||||
assert violation.timestamp_end >= violation.timestamp
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_violation_has_risk_level(self, mock_completed_review):
|
||||
"""违规项包含风险等级"""
|
||||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||||
for violation in parsed.violations:
|
||||
assert violation.severity.value in ["high", "medium", "low"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_violation_has_source(self, mock_completed_review):
|
||||
"""违规项包含来源(语音/画面/字幕)"""
|
||||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||||
for violation in parsed.violations:
|
||||
assert violation.source is not None
|
||||
assert violation.source.value in ["speech", "visual", "subtitle", "text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_violation_has_suggestion(self, mock_completed_review):
|
||||
"""违规项包含修改建议"""
|
||||
parsed = VideoReviewResultResponse.model_validate(mock_completed_review)
|
||||
for violation in parsed.violations:
|
||||
assert isinstance(violation.suggestion, str)
|
||||
assert violation.suggestion
|
||||
|
||||
|
||||
class TestRiskLevelClassification:
|
||||
"""风险等级分类逻辑"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legal_violation_is_high_risk(self):
|
||||
"""法律违规(广告法极限词)标记为高风险"""
|
||||
from app.services.risk import classify_risk_level
|
||||
assert classify_risk_level(ViolationType.FORBIDDEN_WORD) == RiskLevel.HIGH
|
||||
assert classify_risk_level(ViolationType.EFFICACY_CLAIM) == RiskLevel.HIGH
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_violation_is_medium_risk(self):
|
||||
"""平台规则违规标记为中风险"""
|
||||
from app.services.risk import classify_risk_level
|
||||
assert classify_risk_level(ViolationType.COMPETITOR_LOGO) == RiskLevel.MEDIUM
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_guideline_violation_is_low_risk(self):
|
||||
"""品牌规范违规标记为低风险"""
|
||||
from app.services.risk import classify_risk_level
|
||||
assert classify_risk_level(ViolationType.MENTION_MISSING) == RiskLevel.LOW
|
||||
|
||||
|
||||
class TestViolationDetection:
|
||||
"""违规检测场景"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_competitor_logo(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""检测竞品 Logo - 提交成功并返回 review_id"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
"competitors": ["competitor-brand-A", "competitor-brand-B"],
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_forbidden_word_in_speech(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""检测口播中的违禁词(ASR)"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_forbidden_word_in_subtitle(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""检测字幕中的违禁词(OCR)"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
|
||||
|
||||
class TestDurationAndFrequency:
|
||||
"""时长与频次校验 (F-45)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_product_display_duration(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""校验产品同框时长 - 请求参数被接受"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
"requirements": {
|
||||
"min_product_display_seconds": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_brand_mention_frequency(self, client: AsyncClient, tenant_id: str, video_url: str, brand_id: str, creator_id: str):
|
||||
"""校验品牌提及频次 - 请求参数被接受"""
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": video_url,
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
"requirements": {
|
||||
"min_brand_mentions": 3,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_requirement_accepted(self, client: AsyncClient, tenant_id: str, brand_id: str, creator_id: str):
|
||||
"""时长要求参数被正确接受"""
|
||||
# 提交带时长要求的审核请求
|
||||
response = await client.post(
|
||||
"/api/v1/videos/review",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"video_url": "https://example.com/short_display.mp4",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"creator_id": creator_id,
|
||||
"requirements": {
|
||||
"min_product_display_seconds": 10,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 请求应该被接受
|
||||
assert response.status_code == 202
|
||||
parsed = VideoReviewSubmitResponse.model_validate(response.json())
|
||||
assert parsed.review_id
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
视频审核服务层测试 (TDD - 红色阶段)
|
||||
测试覆盖: 违规检测核心逻辑、时长频次校验、风险等级分类
|
||||
这些测试验证实际检测结果,而非仅 HTTP 状态码
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
class TestCompetitorLogoDetection:
|
||||
"""竞品 Logo 检测逻辑"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_competitor_logo_in_frame(self):
|
||||
"""检测画面中的竞品 Logo"""
|
||||
# 导入服务(实现后才能通过)
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 模拟视频帧数据(包含竞品 Logo)
|
||||
mock_frames = [
|
||||
{"timestamp": 10.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.95}]},
|
||||
{"timestamp": 45.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.88}]},
|
||||
]
|
||||
|
||||
violations = await service.detect_competitor_logos(
|
||||
frames=mock_frames,
|
||||
competitors=["competitor-brand-A", "competitor-brand-B"]
|
||||
)
|
||||
|
||||
# 应该检测到 2 处竞品露出
|
||||
assert len(violations) == 2
|
||||
assert violations[0]["type"] == "competitor_logo"
|
||||
assert violations[0]["timestamp"] == 10.0
|
||||
assert violations[0]["risk_level"] == "medium"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_detection_when_no_competitor(self):
|
||||
"""无竞品时不应检测到违规"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
mock_frames = [
|
||||
{"timestamp": 10.0, "objects": [{"label": "product-A", "confidence": 0.95}]},
|
||||
]
|
||||
|
||||
violations = await service.detect_competitor_logos(
|
||||
frames=mock_frames,
|
||||
competitors=["competitor-brand-X"] # 不在画面中
|
||||
)
|
||||
|
||||
assert len(violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignore_low_confidence_detection(self):
|
||||
"""忽略低置信度检测"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
mock_frames = [
|
||||
{"timestamp": 10.0, "objects": [{"label": "competitor-brand-A", "confidence": 0.3}]}, # 低置信度
|
||||
]
|
||||
|
||||
violations = await service.detect_competitor_logos(
|
||||
frames=mock_frames,
|
||||
competitors=["competitor-brand-A"],
|
||||
min_confidence=0.7
|
||||
)
|
||||
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
class TestForbiddenWordDetectionInSpeech:
|
||||
"""口播违禁词检测(ASR)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_forbidden_word_in_transcript(self):
|
||||
"""检测语音转文字中的违禁词"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 模拟 ASR 转写结果
|
||||
mock_transcript = [
|
||||
{"text": "这是一款很好的产品", "start": 0.0, "end": 3.0},
|
||||
{"text": "我们的产品是最好的", "start": 5.0, "end": 8.0}, # 包含"最好"
|
||||
{"text": "销量第一名", "start": 10.0, "end": 12.0}, # 包含"第一"
|
||||
]
|
||||
|
||||
violations = await service.detect_forbidden_words_in_speech(
|
||||
transcript=mock_transcript,
|
||||
forbidden_words=["最好", "第一", "最佳"]
|
||||
)
|
||||
|
||||
# 应该检测到 2 处违规
|
||||
assert len(violations) == 2
|
||||
|
||||
# 验证第一个违规
|
||||
assert violations[0]["type"] == "forbidden_word"
|
||||
assert violations[0]["content"] == "最好"
|
||||
assert violations[0]["timestamp"] == 5.0
|
||||
assert violations[0]["source"] == "speech"
|
||||
assert "suggestion" in violations[0]
|
||||
|
||||
# 验证第二个违规
|
||||
assert violations[1]["content"] == "第一"
|
||||
assert violations[1]["timestamp"] == 10.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_aware_detection(self):
|
||||
"""语境感知检测 - 非广告语境不标记"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 非广告语境
|
||||
mock_transcript = [
|
||||
{"text": "今天是我最开心的一天", "start": 0.0, "end": 3.0}, # 非广告语境
|
||||
]
|
||||
|
||||
violations = await service.detect_forbidden_words_in_speech(
|
||||
transcript=mock_transcript,
|
||||
forbidden_words=["最"],
|
||||
context_aware=True # 启用语境感知
|
||||
)
|
||||
|
||||
# 非广告语境不应标记
|
||||
assert len(violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ad_context_flagged(self):
|
||||
"""广告语境应标记"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 广告语境
|
||||
mock_transcript = [
|
||||
{"text": "我们的产品是最好的选择", "start": 0.0, "end": 3.0},
|
||||
]
|
||||
|
||||
violations = await service.detect_forbidden_words_in_speech(
|
||||
transcript=mock_transcript,
|
||||
forbidden_words=["最好"],
|
||||
context_aware=True
|
||||
)
|
||||
|
||||
# 广告语境应标记
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
class TestForbiddenWordDetectionInSubtitle:
|
||||
"""字幕违禁词检测(OCR)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_forbidden_word_in_subtitle(self):
|
||||
"""检测字幕中的违禁词"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 模拟 OCR 结果
|
||||
mock_subtitles = [
|
||||
{"text": "限时特惠", "timestamp": 5.0},
|
||||
{"text": "效果最佳", "timestamp": 15.0}, # 包含"最佳"
|
||||
{"text": "立即购买", "timestamp": 25.0},
|
||||
]
|
||||
|
||||
violations = await service.detect_forbidden_words_in_subtitle(
|
||||
subtitles=mock_subtitles,
|
||||
forbidden_words=["最佳", "第一", "最好"]
|
||||
)
|
||||
|
||||
assert len(violations) == 1
|
||||
assert violations[0]["content"] == "最佳"
|
||||
assert violations[0]["timestamp"] == 15.0
|
||||
assert violations[0]["source"] == "subtitle"
|
||||
|
||||
|
||||
class TestDurationCheck:
|
||||
"""时长校验"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_product_display_duration_sufficient(self):
|
||||
"""产品同框时长充足时通过"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 模拟产品出现时间段
|
||||
mock_product_appearances = [
|
||||
{"start": 5.0, "end": 15.0}, # 10 秒
|
||||
{"start": 30.0, "end": 35.0}, # 5 秒
|
||||
]
|
||||
|
||||
violations = await service.check_product_display_duration(
|
||||
appearances=mock_product_appearances,
|
||||
min_seconds=10
|
||||
)
|
||||
|
||||
# 总时长 15 秒 >= 要求 10 秒,应该通过
|
||||
assert len(violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_product_display_duration_insufficient(self):
|
||||
"""产品同框时长不足时报违规"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
mock_product_appearances = [
|
||||
{"start": 5.0, "end": 8.0}, # 3 秒
|
||||
]
|
||||
|
||||
violations = await service.check_product_display_duration(
|
||||
appearances=mock_product_appearances,
|
||||
min_seconds=10
|
||||
)
|
||||
|
||||
# 总时长 3 秒 < 要求 10 秒,应该报违规
|
||||
assert len(violations) == 1
|
||||
assert violations[0]["type"] == "duration_short"
|
||||
assert "3" in violations[0]["content"] or "秒" in violations[0]["content"]
|
||||
assert violations[0]["risk_level"] == "medium"
|
||||
|
||||
|
||||
class TestBrandMentionFrequency:
|
||||
"""品牌提及频次校验"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_mention_sufficient(self):
|
||||
"""品牌提及次数充足时通过"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
mock_transcript = [
|
||||
{"text": "今天介绍品牌A的产品", "start": 0.0, "end": 3.0},
|
||||
{"text": "品牌A真的很好用", "start": 10.0, "end": 13.0},
|
||||
{"text": "推荐大家试试品牌A", "start": 20.0, "end": 23.0},
|
||||
]
|
||||
|
||||
violations = await service.check_brand_mention_frequency(
|
||||
transcript=mock_transcript,
|
||||
brand_name="品牌A",
|
||||
min_mentions=3
|
||||
)
|
||||
|
||||
# 提及 3 次 >= 要求 3 次,应该通过
|
||||
assert len(violations) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_mention_insufficient(self):
|
||||
"""品牌提及次数不足时报违规"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
mock_transcript = [
|
||||
{"text": "今天介绍品牌A的产品", "start": 0.0, "end": 3.0},
|
||||
]
|
||||
|
||||
violations = await service.check_brand_mention_frequency(
|
||||
transcript=mock_transcript,
|
||||
brand_name="品牌A",
|
||||
min_mentions=3
|
||||
)
|
||||
|
||||
# 提及 1 次 < 要求 3 次,应该报违规
|
||||
assert len(violations) == 1
|
||||
assert violations[0]["type"] == "mention_missing"
|
||||
|
||||
|
||||
class TestRiskLevelClassification:
|
||||
"""风险等级分类"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legal_violation_is_high_risk(self):
|
||||
"""法律违规(广告法)标记为高风险"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
violation = {
|
||||
"type": "forbidden_word",
|
||||
"content": "最好",
|
||||
"category": "absolute_term", # 广告法极限词
|
||||
}
|
||||
|
||||
risk_level = service.classify_risk_level(violation)
|
||||
assert risk_level == "high"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_violation_is_medium_risk(self):
|
||||
"""平台规则违规标记为中风险"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
violation = {
|
||||
"type": "duration_short",
|
||||
"category": "platform_rule",
|
||||
}
|
||||
|
||||
risk_level = service.classify_risk_level(violation)
|
||||
assert risk_level == "medium"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_guideline_is_low_risk(self):
|
||||
"""品牌规范违规标记为低风险"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
violation = {
|
||||
"type": "mention_missing",
|
||||
"category": "brand_guideline",
|
||||
}
|
||||
|
||||
risk_level = service.classify_risk_level(violation)
|
||||
assert risk_level == "low"
|
||||
|
||||
|
||||
class TestScoreCalculation:
|
||||
"""合规分数计算"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_perfect_score_no_violations(self):
|
||||
"""无违规时满分"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
score = service.calculate_score(violations=[])
|
||||
assert score == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_risk_violation_major_deduction(self):
|
||||
"""高风险违规大幅扣分"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
violations = [
|
||||
{"type": "forbidden_word", "risk_level": "high"},
|
||||
]
|
||||
|
||||
score = service.calculate_score(violations=violations)
|
||||
# 高风险违规应该扣 20-30 分
|
||||
assert score <= 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_violations_cumulative_deduction(self):
|
||||
"""多个违规累计扣分"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
violations = [
|
||||
{"type": "forbidden_word", "risk_level": "high"},
|
||||
{"type": "forbidden_word", "risk_level": "high"},
|
||||
{"type": "duration_short", "risk_level": "medium"},
|
||||
]
|
||||
|
||||
score = service.calculate_score(violations=violations)
|
||||
# 多个违规累计,分数应该更低
|
||||
assert score <= 60
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_never_below_zero(self):
|
||||
"""分数不会低于 0"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# 大量违规
|
||||
violations = [{"type": "forbidden_word", "risk_level": "high"} for _ in range(20)]
|
||||
|
||||
score = service.calculate_score(violations=violations)
|
||||
assert score >= 0
|
||||
|
||||
|
||||
class TestFullReviewPipeline:
|
||||
"""完整审核流程测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_video_with_violations(self):
|
||||
"""审核包含违规的视频"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# Mock AI 服务
|
||||
service.asr_service = AsyncMock()
|
||||
service.asr_service.transcribe.return_value = [
|
||||
{"text": "这是最好的产品", "start": 5.0, "end": 8.0},
|
||||
]
|
||||
|
||||
service.cv_service = AsyncMock()
|
||||
service.cv_service.detect_objects.return_value = [
|
||||
{"timestamp": 10.0, "objects": [{"label": "competitor-A", "confidence": 0.9}]},
|
||||
]
|
||||
|
||||
service.ocr_service = AsyncMock()
|
||||
service.ocr_service.extract_subtitles.return_value = []
|
||||
|
||||
result = await service.review_video(
|
||||
video_url="https://example.com/video.mp4",
|
||||
platform="douyin",
|
||||
brand_id="brand-001",
|
||||
competitors=["competitor-A"],
|
||||
forbidden_words=["最好"],
|
||||
)
|
||||
|
||||
# 验证结果结构
|
||||
assert "score" in result
|
||||
assert "summary" in result
|
||||
assert "violations" in result
|
||||
|
||||
# 应该检测到违规
|
||||
assert len(result["violations"]) >= 2 # 至少:口播违禁词 + 竞品 Logo
|
||||
assert result["score"] < 100
|
||||
|
||||
# 验证违规项结构
|
||||
for violation in result["violations"]:
|
||||
assert "type" in violation
|
||||
assert "content" in violation
|
||||
assert "timestamp" in violation
|
||||
assert "risk_level" in violation
|
||||
assert "suggestion" in violation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_clean_video(self):
|
||||
"""审核无违规的视频"""
|
||||
from app.services.video_review import VideoReviewService
|
||||
|
||||
service = VideoReviewService()
|
||||
|
||||
# Mock AI 服务 - 无违规内容
|
||||
service.asr_service = AsyncMock()
|
||||
service.asr_service.transcribe.return_value = [
|
||||
{"text": "今天给大家分享护肤技巧", "start": 0.0, "end": 3.0},
|
||||
]
|
||||
|
||||
service.cv_service = AsyncMock()
|
||||
service.cv_service.detect_objects.return_value = []
|
||||
|
||||
service.ocr_service = AsyncMock()
|
||||
service.ocr_service.extract_subtitles.return_value = []
|
||||
|
||||
result = await service.review_video(
|
||||
video_url="https://example.com/clean_video.mp4",
|
||||
platform="douyin",
|
||||
brand_id="brand-001",
|
||||
competitors=[],
|
||||
forbidden_words=["最好"],
|
||||
)
|
||||
|
||||
# 无违规,满分
|
||||
assert len(result["violations"]) == 0
|
||||
assert result["score"] == 100
|
||||
Reference in New Issue
Block a user