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
+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,
)