feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+46
View File
@@ -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
+21
View File
@@ -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
+30
View File
@@ -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"]
+64
View File
@@ -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
+92
View File
@@ -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()
+26
View File
@@ -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")
+2
View File
@@ -0,0 +1,2 @@
"""秒思智能审核平台后端服务"""
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
"""API 路由模块"""
+314
View File
@@ -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,
}
+54
View File
@@ -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}
+87
View File
@@ -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,
)
+226
View File
@@ -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)
+535
View File
@@ -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
]
+318
View File
@@ -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 []
+318
View File
@@ -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)
+381
View File
@@ -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,
)
+61
View File
@@ -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), # 每小时整点执行
},
},
)
+40
View File
@@ -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()
+76
View File
@@ -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",
]
+43
View File
@@ -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",
}
+23
View File
@@ -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",
]
+59
View File
@@ -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})>"
+29
View File
@@ -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,
)
+164
View File
@@ -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})>"
+104
View File
@@ -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})>"
+85
View File
@@ -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})>"
+64
View File
@@ -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})>"
+6
View File
@@ -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")
+135
View File
@@ -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:]}"
+312
View File
@@ -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="违规类型列表")
+54
View File
@@ -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",
]
+335
View File
@@ -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,
)
+182
View File
@@ -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)
+310
View File
@@ -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
+138
View File
@@ -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()
+353
View File
@@ -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
+46
View File
@@ -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
+74
View File
@@ -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
+75
View File
@@ -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
+248
View File
@@ -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
+318
View File
@@ -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,
}
+427
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
"""后台任务模块"""
from app.celery_app import celery_app
__all__ = ["celery_app"]
+366
View File
@@ -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}
+9
View File
@@ -0,0 +1,9 @@
"""
工具模块
"""
from app.utils.crypto import encrypt_api_key, decrypt_api_key
__all__ = [
"encrypt_api_key",
"decrypt_api_key",
]
+84
View File
@@ -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:]}"
+95
View File
@@ -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:
+76
View File
@@ -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:",
]
+38
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""测试模块"""
+464
View File
@@ -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
+345
View File
@@ -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
+158
View File
@@ -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"]
+241
View File
@@ -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
+62
View File
@@ -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
+137
View File
@@ -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
+385
View File
@@ -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
+331
View File
@@ -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)
+63
View File
@@ -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 == []
+428
View File
@@ -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
+422
View File
@@ -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
+464
View File
@@ -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