feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -0,0 +1 @@
|
||||
"""API 路由模块"""
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
AI 服务配置 API
|
||||
品牌方管理 AI 提供商配置、模型选择、连通性测试
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.ai_config import (
|
||||
AIProvider,
|
||||
AIConfigUpdate,
|
||||
AIConfigResponse,
|
||||
AIModelsConfig,
|
||||
AIParametersConfig,
|
||||
GetModelsRequest,
|
||||
TestConnectionRequest,
|
||||
ModelsListResponse,
|
||||
ConnectionTestResponse,
|
||||
ModelTestResult,
|
||||
ModelInfo,
|
||||
ModelCapability,
|
||||
mask_api_key,
|
||||
)
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
router = APIRouter(prefix="/ai-config", tags=["ai-config"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.get("", response_model=AIConfigResponse)
|
||||
async def get_ai_config(
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
获取当前 AI 配置
|
||||
|
||||
- 未配置返回 404
|
||||
- 已配置返回配置信息(API Key 脱敏)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == x_tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="AI 服务未配置,请先完成配置",
|
||||
)
|
||||
|
||||
# 解密 API Key 用于脱敏显示
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=config.is_configured,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=AIConfigResponse)
|
||||
async def update_ai_config(
|
||||
request: AIConfigUpdate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
更新 AI 配置
|
||||
|
||||
- 保存提供商、连接信息、模型配置
|
||||
- API Key 加密存储
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 加密 API Key
|
||||
api_key_encrypted = encrypt_api_key(request.api_key)
|
||||
|
||||
# 创建或更新配置
|
||||
config = await AIServiceFactory.create_or_update_config(
|
||||
tenant_id=x_tenant_id,
|
||||
provider=request.provider.value,
|
||||
base_url=request.base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=request.models.model_dump(),
|
||||
temperature=request.parameters.temperature,
|
||||
max_tokens=request.parameters.max_tokens,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(request.api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=True,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelsListResponse)
|
||||
async def get_available_models(
|
||||
request: GetModelsRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ModelsListResponse:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
- 调用提供商 API 获取模型列表
|
||||
- 按能力分类(text/vision/audio)
|
||||
"""
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
models_dict = await client.list_models()
|
||||
await client.close()
|
||||
|
||||
# 转换为 ModelInfo 对象
|
||||
models = {
|
||||
k: [ModelInfo(**m) for m in v]
|
||||
for k, v in models_dict.items()
|
||||
}
|
||||
|
||||
# 更新配置中的可用模型缓存
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.available_models = models_dict
|
||||
await db.flush()
|
||||
|
||||
return ModelsListResponse(
|
||||
success=True,
|
||||
models=models,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"获取模型列表失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=ConnectionTestResponse)
|
||||
async def test_connection(
|
||||
request: TestConnectionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConnectionTestResponse:
|
||||
"""
|
||||
测试 AI 服务连接
|
||||
|
||||
- 并行测试三个模型
|
||||
- 返回每个模型的测试结果
|
||||
"""
|
||||
client = None
|
||||
models = request.models.model_dump()
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
# 定义模型能力映射
|
||||
capability_map = {
|
||||
"text": ModelCapability.TEXT,
|
||||
"vision": ModelCapability.VISION,
|
||||
"audio": ModelCapability.AUDIO,
|
||||
}
|
||||
|
||||
async def test_single(model_type: str, model_id: str) -> tuple[str, ModelTestResult]:
|
||||
capability = capability_map.get(model_type, ModelCapability.TEXT)
|
||||
result = await client.test_connection(model_id, capability)
|
||||
return model_type, ModelTestResult(
|
||||
success=result.success,
|
||||
latency_ms=result.latency_ms,
|
||||
error=result.error,
|
||||
model=model_id,
|
||||
)
|
||||
|
||||
# 并行测试所有模型
|
||||
tasks = [
|
||||
test_single(model_type, model_id)
|
||||
for model_type, model_id in models.items()
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks)
|
||||
results = {model_type: result for model_type, result in results_list}
|
||||
|
||||
# 计算测试结果
|
||||
all_success = all(r.success for r in results.values())
|
||||
failed_count = sum(1 for r in results.values() if not r.success)
|
||||
|
||||
if all_success:
|
||||
message = "所有模型连接成功"
|
||||
else:
|
||||
message = f"{failed_count} 个模型连接失败,请检查模型名称或 API 权限"
|
||||
|
||||
response = ConnectionTestResponse(
|
||||
success=all_success,
|
||||
results=results,
|
||||
message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 确保接口返回 200,并返回失败详情
|
||||
results = {
|
||||
model_type: ModelTestResult(
|
||||
success=False,
|
||||
latency_ms=0,
|
||||
error=str(exc),
|
||||
model=model_id,
|
||||
)
|
||||
for model_type, model_id in models.items()
|
||||
}
|
||||
response = ConnectionTestResponse(
|
||||
success=False,
|
||||
results=results,
|
||||
message=f"连接测试失败: {str(exc)}",
|
||||
)
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 保存测试结果到数据库
|
||||
db_result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = db_result.scalar_one_or_none()
|
||||
if config:
|
||||
config.last_test_at = datetime.now(timezone.utc)
|
||||
config.last_test_result = {
|
||||
k: v.model_dump() for k, v in response.results.items()
|
||||
}
|
||||
await db.flush()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== 供其他模块调用 ====================
|
||||
|
||||
async def get_ai_config_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[dict]:
|
||||
"""获取租户的 AI 配置(供审核服务调用)"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tenant_id": config.tenant_id,
|
||||
"provider": config.provider,
|
||||
"base_url": config.base_url,
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"models": config.models,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""健康检查 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
from app.services.health import HealthChecker, get_health_checker
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
健康检查端点
|
||||
|
||||
Returns:
|
||||
dict: 包含服务状态信息
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness_check(
|
||||
health_checker: HealthChecker = Depends(get_health_checker),
|
||||
):
|
||||
"""
|
||||
就绪检查端点(用于 K8s)
|
||||
检查数据库、Redis 等依赖服务是否就绪
|
||||
|
||||
Returns:
|
||||
dict: 服务就绪状态和依赖检查结果
|
||||
"""
|
||||
checks = await health_checker.check_all()
|
||||
all_ready = all(checks.values())
|
||||
|
||||
return {
|
||||
"ready": all_ready,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def liveness_check():
|
||||
"""
|
||||
存活检查端点(用于 K8s)
|
||||
只检查服务进程是否存活,不检查依赖
|
||||
|
||||
Returns:
|
||||
dict: 服务存活状态
|
||||
"""
|
||||
return {"alive": True}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
一致性指标 API
|
||||
按达人、规则类型、时间窗口查询
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.schemas.review import (
|
||||
ConsistencyMetricsResponse,
|
||||
ConsistencyWindow,
|
||||
RuleConsistencyMetric,
|
||||
ViolationType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/consistency", response_model=ConsistencyMetricsResponse)
|
||||
async def get_consistency_metrics(
|
||||
influencer_id: str = Query(None, description="达人 ID(必填)"),
|
||||
window: ConsistencyWindow = Query(ConsistencyWindow.ROLLING_30D, description="计算周期"),
|
||||
rule_type: ViolationType = Query(None, description="规则类型筛选"),
|
||||
) -> ConsistencyMetricsResponse:
|
||||
"""
|
||||
查询一致性指标
|
||||
|
||||
- 按达人 ID 查询
|
||||
- 支持 Rolling 30 天、周度快照、月度快照
|
||||
- 可按规则类型筛选
|
||||
"""
|
||||
# 验证必填参数
|
||||
if not influencer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="缺少必填参数: influencer_id",
|
||||
)
|
||||
|
||||
# 计算时间范围
|
||||
now = datetime.now(timezone.utc)
|
||||
if window == ConsistencyWindow.ROLLING_30D:
|
||||
period_start = now - timedelta(days=30)
|
||||
period_end = now
|
||||
elif window == ConsistencyWindow.SNAPSHOT_WEEK:
|
||||
# 本周一到现在
|
||||
days_since_monday = now.weekday()
|
||||
period_start = (now - timedelta(days=days_since_monday)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
period_end = now
|
||||
else: # SNAPSHOT_MONTH
|
||||
# 本月1号到现在
|
||||
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
period_end = now
|
||||
|
||||
# 生成模拟数据(实际应从数据库查询)
|
||||
all_metrics = [
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.FORBIDDEN_WORD,
|
||||
total_reviews=100,
|
||||
violation_count=5,
|
||||
violation_rate=0.05,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.COMPETITOR_LOGO,
|
||||
total_reviews=100,
|
||||
violation_count=2,
|
||||
violation_rate=0.02,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.DURATION_SHORT,
|
||||
total_reviews=100,
|
||||
violation_count=8,
|
||||
violation_rate=0.08,
|
||||
),
|
||||
]
|
||||
|
||||
# 按规则类型筛选
|
||||
if rule_type:
|
||||
all_metrics = [m for m in all_metrics if m.rule_type == rule_type]
|
||||
|
||||
return ConsistencyMetricsResponse(
|
||||
influencer_id=influencer_id,
|
||||
window=window,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
metrics=all_metrics,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
特例审批 API
|
||||
创建、查询、审批特例记录
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.risk_exception import (
|
||||
RiskException,
|
||||
RiskTargetType as DBRiskTargetType,
|
||||
RiskExceptionStatus as DBRiskExceptionStatus,
|
||||
)
|
||||
from app.schemas.review import (
|
||||
RiskExceptionCreateRequest,
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
RiskExceptionDecisionRequest,
|
||||
RiskTargetType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/risk-exceptions", tags=["risk-exceptions"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def _exception_to_response(record: RiskException) -> RiskExceptionRecord:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return RiskExceptionRecord(
|
||||
record_id=record.id,
|
||||
applicant_id=record.applicant_id,
|
||||
apply_time=record.apply_time,
|
||||
target_type=RiskTargetType(record.target_type.value),
|
||||
target_id=record.target_id,
|
||||
risk_rule_id=record.risk_rule_id,
|
||||
status=RiskExceptionStatus(record.status.value),
|
||||
valid_start_time=record.valid_start_time,
|
||||
valid_end_time=record.valid_end_time,
|
||||
reason_category=record.reason_category,
|
||||
justification=record.justification,
|
||||
attachment_url=record.attachment_url,
|
||||
current_approver_id=record.current_approver_id,
|
||||
approval_chain_log=record.approval_chain_log or [],
|
||||
auto_rejected=record.auto_rejected,
|
||||
rejection_reason=record.rejection_reason,
|
||||
last_status_at=record.last_status_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RiskExceptionRecord, status_code=status.HTTP_201_CREATED)
|
||||
async def create_exception(
|
||||
request: RiskExceptionCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""创建特例申请"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
record_id = f"exc-{uuid.uuid4().hex[:12]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
record = RiskException(
|
||||
id=record_id,
|
||||
tenant_id=x_tenant_id,
|
||||
applicant_id=request.applicant_id,
|
||||
apply_time=now,
|
||||
target_type=DBRiskTargetType(request.target_type.value),
|
||||
target_id=request.target_id,
|
||||
risk_rule_id=request.risk_rule_id,
|
||||
status=DBRiskExceptionStatus.PENDING,
|
||||
valid_start_time=request.valid_start_time,
|
||||
valid_end_time=request.valid_end_time,
|
||||
reason_category=request.reason_category,
|
||||
justification=request.justification,
|
||||
attachment_url=request.attachment_url,
|
||||
current_approver_id=request.current_approver_id,
|
||||
approval_chain_log=[],
|
||||
auto_rejected=False,
|
||||
rejection_reason=None,
|
||||
last_status_at=now,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.get("/{record_id}", response_model=RiskExceptionRecord)
|
||||
async def get_exception(
|
||||
record_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""查询特例记录"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/approve", response_model=RiskExceptionRecord)
|
||||
async def approve_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""审批通过"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.APPROVED
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "approve",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/reject", response_model=RiskExceptionRecord)
|
||||
async def reject_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""驳回申请"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
# 驳回必须填写原因
|
||||
if not request.comment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="驳回必须填写原因",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.REJECTED
|
||||
record.rejection_reason = request.comment
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "reject",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
规则管理 API
|
||||
违禁词库、白名单、竞品库、平台规则
|
||||
"""
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
|
||||
# ==================== 请求/响应模型 ====================
|
||||
|
||||
class ForbiddenWordCreate(BaseModel):
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordResponse(BaseModel):
|
||||
id: str
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordListResponse(BaseModel):
|
||||
items: list[ForbiddenWordResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WhitelistCreate(BaseModel):
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistResponse(BaseModel):
|
||||
id: str
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistListResponse(BaseModel):
|
||||
items: list[WhitelistResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CompetitorCreate(BaseModel):
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorListResponse(BaseModel):
|
||||
items: list[CompetitorResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PlatformRuleResponse(BaseModel):
|
||||
platform: str
|
||||
rules: list[dict]
|
||||
version: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PlatformListResponse(BaseModel):
|
||||
items: list[PlatformRuleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class RuleValidateRequest(BaseModel):
|
||||
brand_id: str
|
||||
platform: str
|
||||
brief_rules: dict
|
||||
|
||||
|
||||
class RuleConflict(BaseModel):
|
||||
brief_rule: str
|
||||
platform_rule: str
|
||||
suggestion: str
|
||||
|
||||
|
||||
class RuleValidateResponse(BaseModel):
|
||||
conflicts: list[RuleConflict]
|
||||
|
||||
|
||||
# ==================== 预置平台规则 ====================
|
||||
|
||||
_platform_rules = {
|
||||
"douyin": {
|
||||
"platform": "douyin",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一", "最佳", "绝对", "100%"]},
|
||||
{"type": "duration", "min_seconds": 7},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-15T00:00:00Z",
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"platform": "xiaohongshu",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-10T00:00:00Z",
|
||||
},
|
||||
"bilibili": {
|
||||
"platform": "bilibili",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-12T00:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
# ==================== 违禁词库 ====================
|
||||
|
||||
@router.get("/forbidden-words", response_model=ForbiddenWordListResponse)
|
||||
async def list_forbidden_words(
|
||||
category: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordListResponse:
|
||||
"""查询违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == x_tenant_id)
|
||||
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return ForbiddenWordListResponse(
|
||||
items=[
|
||||
ForbiddenWordResponse(
|
||||
id=w.id,
|
||||
word=w.word,
|
||||
category=w.category,
|
||||
severity=w.severity,
|
||||
)
|
||||
for w in words
|
||||
],
|
||||
total=len(words),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/forbidden-words",
|
||||
response_model=ForbiddenWordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_forbidden_word(
|
||||
request: ForbiddenWordCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordResponse:
|
||||
"""添加违禁词"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 检查重复
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
ForbiddenWord.word == request.word,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"违禁词已存在: {request.word}",
|
||||
)
|
||||
|
||||
word_id = f"fw-{uuid.uuid4().hex[:8]}"
|
||||
word = ForbiddenWord(
|
||||
id=word_id,
|
||||
tenant_id=x_tenant_id,
|
||||
word=request.word,
|
||||
category=request.category,
|
||||
severity=request.severity,
|
||||
)
|
||||
db.add(word)
|
||||
await db.flush()
|
||||
|
||||
return ForbiddenWordResponse(
|
||||
id=word.id,
|
||||
word=word.word,
|
||||
category=word.category,
|
||||
severity=word.severity,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/forbidden-words/{word_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_forbidden_word(
|
||||
word_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除违禁词"""
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.id == word_id,
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
word = result.scalar_one_or_none()
|
||||
|
||||
if not word:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"违禁词不存在: {word_id}",
|
||||
)
|
||||
|
||||
await db.delete(word)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 白名单 ====================
|
||||
|
||||
@router.get("/whitelist", response_model=WhitelistListResponse)
|
||||
async def list_whitelist(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistListResponse:
|
||||
"""查询白名单"""
|
||||
query = select(WhitelistItem).where(WhitelistItem.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(WhitelistItem.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return WhitelistListResponse(
|
||||
items=[
|
||||
WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/whitelist",
|
||||
response_model=WhitelistResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_to_whitelist(
|
||||
request: WhitelistCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistResponse:
|
||||
"""添加白名单"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
item_id = f"wl-{uuid.uuid4().hex[:8]}"
|
||||
item = WhitelistItem(
|
||||
id=item_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
term=request.term,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
|
||||
return WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 竞品库 ====================
|
||||
|
||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||
async def list_competitors(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorListResponse:
|
||||
"""查询竞品列表"""
|
||||
query = select(Competitor).where(Competitor.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(Competitor.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
competitors = result.scalars().all()
|
||||
|
||||
return CompetitorListResponse(
|
||||
items=[
|
||||
CompetitorResponse(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
brand_id=c.brand_id,
|
||||
logo_url=c.logo_url,
|
||||
keywords=c.keywords or [],
|
||||
)
|
||||
for c in competitors
|
||||
],
|
||||
total=len(competitors),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/competitors",
|
||||
response_model=CompetitorResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_competitor(
|
||||
request: CompetitorCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorResponse:
|
||||
"""添加竞品"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
comp_id = f"comp-{uuid.uuid4().hex[:8]}"
|
||||
competitor = Competitor(
|
||||
id=comp_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
name=request.name,
|
||||
logo_url=request.logo_url,
|
||||
keywords=request.keywords,
|
||||
)
|
||||
db.add(competitor)
|
||||
await db.flush()
|
||||
|
||||
return CompetitorResponse(
|
||||
id=competitor.id,
|
||||
name=competitor.name,
|
||||
brand_id=competitor.brand_id,
|
||||
logo_url=competitor.logo_url,
|
||||
keywords=competitor.keywords or [],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/competitors/{competitor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_competitor(
|
||||
competitor_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除竞品"""
|
||||
result = await db.execute(
|
||||
select(Competitor).where(
|
||||
and_(
|
||||
Competitor.id == competitor_id,
|
||||
Competitor.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
competitor = result.scalar_one_or_none()
|
||||
|
||||
if not competitor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"竞品不存在: {competitor_id}",
|
||||
)
|
||||
|
||||
await db.delete(competitor)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 平台规则 ====================
|
||||
|
||||
@router.get("/platforms", response_model=PlatformListResponse)
|
||||
async def list_platform_rules() -> PlatformListResponse:
|
||||
"""查询所有平台规则"""
|
||||
return PlatformListResponse(
|
||||
items=[PlatformRuleResponse(**r) for r in _platform_rules.values()],
|
||||
total=len(_platform_rules),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platforms/{platform}", response_model=PlatformRuleResponse)
|
||||
async def get_platform_rules(platform: str) -> PlatformRuleResponse:
|
||||
"""查询指定平台规则"""
|
||||
if platform not in _platform_rules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"平台不存在: {platform}",
|
||||
)
|
||||
return PlatformRuleResponse(**_platform_rules[platform])
|
||||
|
||||
|
||||
# ==================== 规则冲突检测 ====================
|
||||
|
||||
@router.post("/validate", response_model=RuleValidateResponse)
|
||||
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
conflicts = []
|
||||
|
||||
platform_rule = _platform_rules.get(request.platform)
|
||||
if not platform_rule:
|
||||
return RuleValidateResponse(conflicts=[])
|
||||
|
||||
# 检查 required_phrases 是否包含违禁词
|
||||
required_phrases = request.brief_rules.get("required_phrases", [])
|
||||
platform_forbidden = []
|
||||
for rule in platform_rule.get("rules", []):
|
||||
if rule.get("type") == "forbidden_word":
|
||||
platform_forbidden.extend(rule.get("words", []))
|
||||
|
||||
for phrase in required_phrases:
|
||||
for word in platform_forbidden:
|
||||
if word in phrase:
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"要求使用:{phrase}",
|
||||
platform_rule=f"平台禁止:{word}",
|
||||
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
|
||||
))
|
||||
|
||||
return RuleValidateResponse(conflicts=conflicts)
|
||||
|
||||
|
||||
# ==================== 辅助函数(供其他模块调用) ====================
|
||||
|
||||
async def get_whitelist_for_brand(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[str]:
|
||||
"""获取品牌白名单词汇"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [item.term for item in items]
|
||||
|
||||
|
||||
async def get_other_brands_whitelist_terms(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""
|
||||
获取其他品牌的白名单词汇(用于品牌安全检测)
|
||||
|
||||
Returns:
|
||||
list of (term, owner_brand_id)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id != brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [(item.term, item.brand_id) for item in items]
|
||||
|
||||
|
||||
async def get_forbidden_words_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
category: str = None,
|
||||
) -> list[dict]:
|
||||
"""获取租户的违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == tenant_id)
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"word": w.word,
|
||||
"category": w.category,
|
||||
"severity": w.severity,
|
||||
}
|
||||
for w in words
|
||||
]
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
脚本预审 API
|
||||
"""
|
||||
import re
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.review import (
|
||||
ScriptReviewRequest,
|
||||
ScriptReviewResponse,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
Position,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.api.rules import (
|
||||
get_whitelist_for_brand,
|
||||
get_other_brands_whitelist_terms,
|
||||
get_forbidden_words_for_tenant,
|
||||
)
|
||||
from app.services.soft_risk import evaluate_soft_risk
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
|
||||
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||
|
||||
# 内置违禁词库(广告极限词)
|
||||
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
||||
|
||||
# 功效词库(医疗/功效宣称)
|
||||
EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
||||
|
||||
# 广告语境关键词(用于判断是否为广告场景)
|
||||
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
||||
|
||||
|
||||
def _is_ad_context(content: str, word: str) -> bool:
|
||||
"""
|
||||
判断是否为广告语境
|
||||
|
||||
规则:
|
||||
- 如果内容中包含广告关键词,认为是广告语境
|
||||
- 如果违禁词出现在明显的非广告句式中,不是广告语境
|
||||
"""
|
||||
# 非广告语境模式
|
||||
non_ad_patterns = [
|
||||
r"他是第一[个名位]", # 他是第一个/名
|
||||
r"[是为]第一[个名位]", # 是第一个
|
||||
r"最开心|最高兴|最难忘", # 情感表达
|
||||
r"第一[次个].*[到来抵达]", # 第一次到达
|
||||
]
|
||||
|
||||
for pattern in non_ad_patterns:
|
||||
if re.search(pattern, content):
|
||||
return False
|
||||
|
||||
# 检查是否包含广告关键词
|
||||
return any(kw in content for kw in AD_CONTEXT_KEYWORDS)
|
||||
|
||||
|
||||
def _check_selling_point_coverage(content: str, required_points: list[str]) -> list[str]:
|
||||
"""
|
||||
检查卖点覆盖情况
|
||||
|
||||
使用语义匹配而非精确匹配
|
||||
"""
|
||||
missing = []
|
||||
|
||||
# 卖点关键词映射
|
||||
point_keywords = {
|
||||
"品牌名称": ["品牌", "牌子", "品牌A", "品牌B"],
|
||||
"使用方法": ["使用", "用法", "早晚", "每天", "一次", "涂抹", "喷洒"],
|
||||
"功效说明": ["功效", "效果", "水润", "美白", "保湿", "滋润", "改善"],
|
||||
}
|
||||
|
||||
for point in required_points:
|
||||
# 精确匹配
|
||||
if point in content:
|
||||
continue
|
||||
|
||||
# 关键词匹配
|
||||
keywords = point_keywords.get(point, [])
|
||||
if any(kw in content for kw in keywords):
|
||||
continue
|
||||
|
||||
missing.append(point)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
@router.post("/review", response_model=ScriptReviewResponse)
|
||||
async def review_script(
|
||||
request: ScriptReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ScriptReviewResponse:
|
||||
"""
|
||||
脚本预审
|
||||
|
||||
- 检测违禁词(支持语境感知)
|
||||
- 检测功效词
|
||||
- 检查必要卖点
|
||||
- 应用白名单
|
||||
- 可选 AI 深度分析
|
||||
- 返回合规分数和修改建议
|
||||
"""
|
||||
violations = []
|
||||
content = request.content
|
||||
|
||||
# 获取品牌白名单
|
||||
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
||||
|
||||
# 获取租户自定义违禁词
|
||||
tenant_forbidden_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
||||
|
||||
# 1. 违禁词检测(广告极限词)
|
||||
all_forbidden_words = ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]
|
||||
|
||||
for word in all_forbidden_words:
|
||||
# 白名单跳过
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
# 语境感知:非广告语境跳过
|
||||
if not _is_ad_context(content, word):
|
||||
start = pos + 1
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.FORBIDDEN_WORD,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"建议删除或替换违禁词:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 2. 功效词检测
|
||||
for word in EFFICACY_WORDS:
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.EFFICACY_CLAIM,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"功效宣称词违反广告法,建议删除:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 3. 检测其他品牌专属词(品牌安全风险)
|
||||
other_brand_terms = await get_other_brands_whitelist_terms(x_tenant_id, request.brand_id, db)
|
||||
for term, owner_brand in other_brand_terms:
|
||||
if term in content:
|
||||
violations.append(Violation(
|
||||
type=ViolationType.BRAND_SAFETY,
|
||||
content=term,
|
||||
severity=RiskLevel.MEDIUM,
|
||||
suggestion=f"使用了其他品牌的专属词汇:{term}",
|
||||
position=Position(start=content.find(term), end=content.find(term) + len(term)),
|
||||
))
|
||||
|
||||
# 4. 检查遗漏卖点
|
||||
missing_points: list[str] | None = None
|
||||
if request.required_points:
|
||||
missing = _check_selling_point_coverage(content, request.required_points)
|
||||
missing_points = missing if missing else []
|
||||
|
||||
# 5. 可选:AI 深度分析
|
||||
ai_violations = await _ai_deep_analysis(x_tenant_id, content, db)
|
||||
if ai_violations:
|
||||
violations.extend(ai_violations)
|
||||
|
||||
# 6. 计算分数
|
||||
score = 100 - len(violations) * 25
|
||||
if missing_points:
|
||||
score -= len(missing_points) * 5
|
||||
score = max(0, score)
|
||||
|
||||
# 7. 生成摘要
|
||||
parts = []
|
||||
if violations:
|
||||
parts.append(f"发现 {len(violations)} 处违规")
|
||||
if missing_points:
|
||||
parts.append(f"遗漏 {len(missing_points)} 个卖点")
|
||||
|
||||
if not parts:
|
||||
summary = "脚本内容合规,未发现问题"
|
||||
else:
|
||||
summary = ",".join(parts)
|
||||
|
||||
# 8. 软性风控评估
|
||||
soft_warnings: list[SoftRiskWarning] = []
|
||||
if request.soft_risk_context:
|
||||
soft_warnings = evaluate_soft_risk(request.soft_risk_context)
|
||||
|
||||
return ScriptReviewResponse(
|
||||
score=score,
|
||||
summary=summary,
|
||||
violations=violations,
|
||||
missing_points=missing_points,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
async def _ai_deep_analysis(
|
||||
tenant_id: str,
|
||||
content: str,
|
||||
db: AsyncSession,
|
||||
) -> list[Violation]:
|
||||
"""
|
||||
使用 AI 进行深度分析
|
||||
|
||||
AI 分析失败时返回空列表,降级到规则检测
|
||||
"""
|
||||
try:
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
if not ai_client:
|
||||
return []
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
if not config:
|
||||
return []
|
||||
|
||||
text_model = config.models.get("text", "gpt-4o")
|
||||
|
||||
# 构建分析提示
|
||||
analysis_prompt = f"""作为广告合规审核专家,请分析以下广告脚本内容,检测潜在的合规风险:
|
||||
|
||||
脚本内容:
|
||||
{content}
|
||||
|
||||
请检查以下方面:
|
||||
1. 是否存在隐性的虚假宣传(如暗示疗效但不直接说明)
|
||||
2. 是否存在容易引起误解的表述
|
||||
3. 是否存在夸大描述
|
||||
4. 是否存在可能违反广告法的其他内容
|
||||
|
||||
如果发现问题,请以 JSON 数组格式返回,每项包含:
|
||||
- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety)
|
||||
- content: 违规内容
|
||||
- severity: 严重程度 (high/medium/low)
|
||||
- suggestion: 修改建议
|
||||
|
||||
如果未发现问题,返回空数组 []
|
||||
|
||||
请只返回 JSON 数组,不要包含其他内容。"""
|
||||
|
||||
response = await ai_client.chat_completion(
|
||||
messages=[{"role": "user", "content": analysis_prompt}],
|
||||
model=text_model,
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
# 解析 AI 响应
|
||||
import json
|
||||
try:
|
||||
# 清理响应内容(移除可能的 markdown 标记)
|
||||
response_content = response.content.strip()
|
||||
if response_content.startswith("```"):
|
||||
response_content = response_content.split("\n", 1)[1]
|
||||
if response_content.endswith("```"):
|
||||
response_content = response_content.rsplit("\n", 1)[0]
|
||||
|
||||
ai_results = json.loads(response_content)
|
||||
|
||||
violations = []
|
||||
for item in ai_results:
|
||||
violation_type = item.get("type", "forbidden_word")
|
||||
if violation_type == "forbidden_word":
|
||||
vtype = ViolationType.FORBIDDEN_WORD
|
||||
elif violation_type == "efficacy_claim":
|
||||
vtype = ViolationType.EFFICACY_CLAIM
|
||||
else:
|
||||
vtype = ViolationType.BRAND_SAFETY
|
||||
|
||||
severity = item.get("severity", "medium")
|
||||
if severity == "high":
|
||||
slevel = RiskLevel.HIGH
|
||||
elif severity == "low":
|
||||
slevel = RiskLevel.LOW
|
||||
else:
|
||||
slevel = RiskLevel.MEDIUM
|
||||
|
||||
violations.append(Violation(
|
||||
type=vtype,
|
||||
content=item.get("content", ""),
|
||||
severity=slevel,
|
||||
suggestion=item.get("suggestion", "建议修改"),
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,返回空列表
|
||||
return []
|
||||
|
||||
except Exception:
|
||||
# AI 调用失败,降级到规则检测
|
||||
return []
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
审核任务 API
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ManualTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
TaskCreateRequest,
|
||||
TaskResponse,
|
||||
TaskListResponse,
|
||||
TaskScriptUploadRequest,
|
||||
TaskVideoUploadRequest,
|
||||
TaskApproveRequest,
|
||||
TaskRejectRequest,
|
||||
TaskStatus,
|
||||
Platform,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def _task_to_response(task: ManualTask) -> TaskResponse:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return TaskResponse(
|
||||
task_id=task.id,
|
||||
video_url=task.video_url,
|
||||
script_content=task.script_content,
|
||||
script_file_url=task.script_file_url,
|
||||
has_script=bool(task.script_content or task.script_file_url),
|
||||
has_video=bool(task.video_url),
|
||||
platform=Platform(task.platform.value),
|
||||
creator_id=task.creator_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
created_at=task.created_at.isoformat() if task.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: TaskCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
创建审核任务
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
task_id = f"task-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
task = ManualTask(
|
||||
id=task_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url) if request.video_url else None,
|
||||
video_uploaded_at=datetime.now(timezone.utc) if request.video_url else None,
|
||||
platform=DBPlatform(request.platform.value),
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
script_content=request.script_content,
|
||||
script_file_url=str(request.script_file_url) if request.script_file_url else None,
|
||||
script_uploaded_at=datetime.now(timezone.utc)
|
||||
if request.script_content or request.script_file_url
|
||||
else None,
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/script", response_model=TaskResponse)
|
||||
async def upload_task_script(
|
||||
task_id: str,
|
||||
request: TaskScriptUploadRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
上传/更新任务脚本
|
||||
"""
|
||||
if not request.script_content and not request.script_file_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="script_content 或 script_file_url 至少提供一个",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.script_content = request.script_content
|
||||
task.script_file_url = (
|
||||
str(request.script_file_url) if request.script_file_url else None
|
||||
)
|
||||
task.script_uploaded_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/video", response_model=TaskResponse)
|
||||
async def upload_task_video(
|
||||
task_id: str,
|
||||
request: TaskVideoUploadRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
上传/更新任务视频
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.video_url = str(request.video_url)
|
||||
task.video_uploaded_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
查询单个任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
task_status: TaskStatus = Query(None, alias="status"),
|
||||
platform: Platform = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskListResponse:
|
||||
"""
|
||||
查询任务列表
|
||||
|
||||
支持分页和筛选
|
||||
"""
|
||||
# 构建查询
|
||||
query = select(ManualTask).where(ManualTask.tenant_id == x_tenant_id)
|
||||
|
||||
if task_status:
|
||||
query = query.where(ManualTask.status == DBTaskStatus(task_status.value))
|
||||
|
||||
if platform:
|
||||
query = query.where(ManualTask.platform == DBPlatform(platform.value))
|
||||
|
||||
# 按创建时间倒序排列
|
||||
query = query.order_by(ManualTask.created_at.desc())
|
||||
|
||||
# 执行查询获取总数
|
||||
count_result = await db.execute(
|
||||
select(ManualTask.id).where(ManualTask.tenant_id == x_tenant_id)
|
||||
)
|
||||
total = len(count_result.all())
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return TaskListResponse(
|
||||
items=[_task_to_response(t) for t in tasks],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/approve", response_model=TaskResponse)
|
||||
async def approve_task(
|
||||
task_id: str,
|
||||
request: TaskApproveRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
通过任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.status = DBTaskStatus.APPROVED
|
||||
task.approve_comment = request.comment
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/reject", response_model=TaskResponse)
|
||||
async def reject_task(
|
||||
task_id: str,
|
||||
request: TaskRejectRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
驳回任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.status = DBTaskStatus.REJECTED
|
||||
task.reject_reason = request.reason
|
||||
task.reject_violations = request.violations
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
视频审核 API
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
VideoReviewRequest,
|
||||
VideoReviewSubmitResponse,
|
||||
VideoReviewProgressResponse,
|
||||
VideoReviewResultResponse,
|
||||
TaskStatus,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
ViolationSource,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review",
|
||||
response_model=VideoReviewSubmitResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def submit_video_review(
|
||||
request: VideoReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewSubmitResponse:
|
||||
"""
|
||||
提交视频审核
|
||||
|
||||
返回 202 Accepted,异步处理
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
review_id = f"review-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# 创建审核任务
|
||||
task = ReviewTask(
|
||||
id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
platform=DBPlatform(request.platform.value),
|
||||
brand_id=request.brand_id,
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
progress=0,
|
||||
current_step="等待处理",
|
||||
competitors=request.competitors,
|
||||
requirements=request.requirements,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 触发 Celery 异步任务
|
||||
try:
|
||||
from app.tasks.review import process_video_review_task
|
||||
process_video_review_task.delay(
|
||||
review_id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
brand_id=request.brand_id,
|
||||
platform=request.platform.value,
|
||||
)
|
||||
except Exception:
|
||||
# Celery 不可用时,任务保持 PENDING 状态
|
||||
# 后续可通过定时任务或手动触发处理
|
||||
pass
|
||||
|
||||
return VideoReviewSubmitResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/{review_id}/progress",
|
||||
response_model=VideoReviewProgressResponse,
|
||||
)
|
||||
async def get_review_progress(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewProgressResponse:
|
||||
"""
|
||||
查询审核进度
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
return VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{review_id}/result")
|
||||
async def get_review_result(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查询审核结果
|
||||
|
||||
- 未完成:返回 202 + 进度结构
|
||||
- 已完成:返回 200 + 结果结构
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
# 未完成:返回 202 + 进度
|
||||
if task.status in [DBTaskStatus.PENDING, DBTaskStatus.PROCESSING]:
|
||||
progress_response = VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
content=progress_response.model_dump(),
|
||||
)
|
||||
|
||||
# 失败:返回错误信息
|
||||
if task.status == DBTaskStatus.FAILED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task.error_message or "审核任务失败",
|
||||
)
|
||||
|
||||
# 已完成:返回 200 + 结果
|
||||
violations = []
|
||||
if task.violations:
|
||||
for v in task.violations:
|
||||
violations.append(Violation(**v))
|
||||
|
||||
soft_warnings = []
|
||||
if task.soft_warnings:
|
||||
for w in task.soft_warnings:
|
||||
soft_warnings.append(SoftRiskWarning(**w))
|
||||
|
||||
return VideoReviewResultResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.COMPLETED,
|
||||
score=task.score or 100,
|
||||
summary=task.summary or "审核完成",
|
||||
violations=violations,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
# ==================== AI 辅助审核方法 ====================
|
||||
|
||||
async def _perform_ai_video_review(
|
||||
task: ReviewTask,
|
||||
ai_client: OpenAICompatibleClient,
|
||||
text_model: str,
|
||||
vision_model: str,
|
||||
audio_model: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
使用 AI 执行视频审核
|
||||
|
||||
流程:
|
||||
1. 下载视频
|
||||
2. ASR 转写
|
||||
3. 提取关键帧
|
||||
4. 视觉分析 (竞品 Logo)
|
||||
5. OCR 字幕
|
||||
6. 生成报告
|
||||
"""
|
||||
violations = []
|
||||
score = 100
|
||||
|
||||
try:
|
||||
# 更新进度: 开始处理
|
||||
task.status = DBTaskStatus.PROCESSING
|
||||
task.progress = 10
|
||||
task.current_step = "下载视频"
|
||||
await db.flush()
|
||||
|
||||
# TODO: 实际实现需要集成视频处理库
|
||||
# 1. 下载视频
|
||||
# video_path = await download_video(task.video_url)
|
||||
|
||||
# 2. ASR 转写
|
||||
task.progress = 30
|
||||
task.current_step = "语音转写"
|
||||
await db.flush()
|
||||
|
||||
# asr_result = await ai_client.audio_transcription(
|
||||
# audio_url=task.video_url, # 需要提取音频
|
||||
# model=audio_model,
|
||||
# )
|
||||
# transcript = asr_result.content
|
||||
|
||||
# 3. 提取关键帧
|
||||
task.progress = 50
|
||||
task.current_step = "提取关键帧"
|
||||
await db.flush()
|
||||
|
||||
# frames = await extract_keyframes(video_path)
|
||||
|
||||
# 4. 视觉分析
|
||||
task.progress = 70
|
||||
task.current_step = "视觉分析"
|
||||
await db.flush()
|
||||
|
||||
# 检测竞品 Logo
|
||||
# if task.competitors:
|
||||
# vision_prompt = f"""
|
||||
# 分析这些视频截图,检测是否包含以下竞品品牌的 Logo 或标识:
|
||||
# 竞品列表: {task.competitors}
|
||||
#
|
||||
# 如果发现竞品,请返回:
|
||||
# 1. 竞品名称
|
||||
# 2. 出现的帧编号
|
||||
# 3. 置信度 (0-1)
|
||||
# """
|
||||
# vision_result = await ai_client.vision_analysis(
|
||||
# image_urls=frames,
|
||||
# prompt=vision_prompt,
|
||||
# model=vision_model,
|
||||
# )
|
||||
|
||||
# 5. 文本综合分析
|
||||
task.progress = 85
|
||||
task.current_step = "综合分析"
|
||||
await db.flush()
|
||||
|
||||
# analysis_prompt = f"""
|
||||
# 作为广告合规审核专家,请分析以下视频脚本内容:
|
||||
#
|
||||
# 脚本内容:
|
||||
# {transcript}
|
||||
#
|
||||
# 请检查:
|
||||
# 1. 是否包含广告法违禁词(最好、第一、最佳等极限词)
|
||||
# 2. 是否包含虚假功效宣称
|
||||
# 3. 品牌信息是否正确
|
||||
#
|
||||
# 返回 JSON 格式:
|
||||
# {{"violations": [...], "score": 0-100, "summary": "..."}}
|
||||
# """
|
||||
# analysis_result = await ai_client.chat_completion(
|
||||
# messages=[{"role": "user", "content": analysis_prompt}],
|
||||
# model=text_model,
|
||||
# )
|
||||
|
||||
# 6. 完成审核
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = score
|
||||
task.summary = "审核完成,未发现违规" if not violations else f"发现 {len(violations)} 处违规"
|
||||
task.violations = [v.model_dump() for v in violations] if violations else []
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": task.summary,
|
||||
"violations": violations,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
task.status = DBTaskStatus.FAILED
|
||||
task.error_message = str(e)
|
||||
await db.flush()
|
||||
raise
|
||||
|
||||
|
||||
# ==================== 后台任务入口 ====================
|
||||
|
||||
async def process_video_review_task(
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
处理视频审核任务(由 Celery 或后台任务调用)
|
||||
"""
|
||||
# 获取任务
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
|
||||
if not ai_client:
|
||||
# 没有配置 AI,使用规则引擎审核
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = 100
|
||||
task.summary = "审核完成(规则引擎)"
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
await db.flush()
|
||||
return
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
models = config.models
|
||||
|
||||
# 执行 AI 审核
|
||||
await _perform_ai_video_review(
|
||||
task=task,
|
||||
ai_client=ai_client,
|
||||
text_model=models.get("text", "gpt-4o"),
|
||||
vision_model=models.get("vision", "gpt-4o"),
|
||||
audio_model=models.get("audio", "whisper-1"),
|
||||
db=db,
|
||||
)
|
||||
Reference in New Issue
Block a user