refactor: 清理无用模块、修复前后端对齐、添加注册页面
- 删除后端 risk_exceptions 模块(API/Model/Schema/迁移/测试) - 删除后端 metrics 模块(API/测试) - 删除后端 ManualTask 模型和相关 Schema - 修复搜索接口响应缺少 total 字段的问题 - 统一 Platform 枚举(前端去掉后端不支持的 weibo/wechat) - 新增前端注册页面 /register,登录页添加注册链接 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a32102f583
commit
4a3c7e7923
@ -18,11 +18,9 @@ from app.models import (
|
|||||||
Tenant,
|
Tenant,
|
||||||
AIConfig,
|
AIConfig,
|
||||||
ReviewTask,
|
ReviewTask,
|
||||||
ManualTask,
|
|
||||||
ForbiddenWord,
|
ForbiddenWord,
|
||||||
WhitelistItem,
|
WhitelistItem,
|
||||||
Competitor,
|
Competitor,
|
||||||
RiskException,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Alembic Config 对象
|
# Alembic Config 对象
|
||||||
|
|||||||
@ -32,18 +32,6 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
task_status_enum.create(op.get_bind(), checkfirst=True)
|
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
risk_target_type_enum = postgresql.ENUM(
|
|
||||||
'influencer', 'order', 'content',
|
|
||||||
name='risk_target_type_enum'
|
|
||||||
)
|
|
||||||
risk_target_type_enum.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
risk_exception_status_enum = postgresql.ENUM(
|
|
||||||
'pending', 'approved', 'rejected', 'expired', 'revoked',
|
|
||||||
name='risk_exception_status_enum'
|
|
||||||
)
|
|
||||||
risk_exception_status_enum.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
# 租户表
|
# 租户表
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'tenants',
|
'tenants',
|
||||||
@ -101,29 +89,6 @@ def upgrade() -> None:
|
|||||||
op.create_index('ix_review_tasks_creator_id', 'review_tasks', ['creator_id'])
|
op.create_index('ix_review_tasks_creator_id', 'review_tasks', ['creator_id'])
|
||||||
op.create_index('ix_review_tasks_status', 'review_tasks', ['status'])
|
op.create_index('ix_review_tasks_status', 'review_tasks', ['status'])
|
||||||
|
|
||||||
# 人工任务表
|
|
||||||
op.create_table(
|
|
||||||
'manual_tasks',
|
|
||||||
sa.Column('id', sa.String(64), primary_key=True),
|
|
||||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('review_task_id', sa.String(64), sa.ForeignKey('review_tasks.id', ondelete='SET NULL'), nullable=True),
|
|
||||||
sa.Column('video_url', sa.String(2048), nullable=False),
|
|
||||||
sa.Column('platform', platform_enum, nullable=False),
|
|
||||||
sa.Column('creator_id', sa.String(64), nullable=False),
|
|
||||||
sa.Column('status', task_status_enum, nullable=False, default='pending'),
|
|
||||||
sa.Column('approve_comment', sa.Text(), nullable=True),
|
|
||||||
sa.Column('reject_reason', sa.Text(), nullable=True),
|
|
||||||
sa.Column('reject_violations', postgresql.JSONB(), nullable=True),
|
|
||||||
sa.Column('reviewer_id', sa.String(64), nullable=True),
|
|
||||||
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
op.create_index('ix_manual_tasks_tenant_id', 'manual_tasks', ['tenant_id'])
|
|
||||||
op.create_index('ix_manual_tasks_review_task_id', 'manual_tasks', ['review_task_id'])
|
|
||||||
op.create_index('ix_manual_tasks_creator_id', 'manual_tasks', ['creator_id'])
|
|
||||||
op.create_index('ix_manual_tasks_status', 'manual_tasks', ['status'])
|
|
||||||
|
|
||||||
# 违禁词表
|
# 违禁词表
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'forbidden_words',
|
'forbidden_words',
|
||||||
@ -169,49 +134,17 @@ def upgrade() -> None:
|
|||||||
op.create_index('ix_competitors_tenant_id', 'competitors', ['tenant_id'])
|
op.create_index('ix_competitors_tenant_id', 'competitors', ['tenant_id'])
|
||||||
op.create_index('ix_competitors_brand_id', 'competitors', ['brand_id'])
|
op.create_index('ix_competitors_brand_id', 'competitors', ['brand_id'])
|
||||||
|
|
||||||
# 特例审批表
|
|
||||||
op.create_table(
|
|
||||||
'risk_exceptions',
|
|
||||||
sa.Column('id', sa.String(64), primary_key=True),
|
|
||||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
|
||||||
sa.Column('applicant_id', sa.String(64), nullable=False),
|
|
||||||
sa.Column('apply_time', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('target_type', risk_target_type_enum, nullable=False),
|
|
||||||
sa.Column('target_id', sa.String(64), nullable=False),
|
|
||||||
sa.Column('risk_rule_id', sa.String(64), nullable=False),
|
|
||||||
sa.Column('status', risk_exception_status_enum, nullable=False, default='pending'),
|
|
||||||
sa.Column('valid_start_time', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('valid_end_time', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('reason_category', sa.String(100), nullable=False),
|
|
||||||
sa.Column('justification', sa.Text(), nullable=False),
|
|
||||||
sa.Column('attachment_url', sa.String(2048), nullable=True),
|
|
||||||
sa.Column('current_approver_id', sa.String(64), nullable=True),
|
|
||||||
sa.Column('approval_chain_log', postgresql.JSONB(), nullable=False, server_default='[]'),
|
|
||||||
sa.Column('auto_rejected', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('rejection_reason', sa.Text(), nullable=True),
|
|
||||||
sa.Column('last_status_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
|
||||||
)
|
|
||||||
op.create_index('ix_risk_exceptions_tenant_id', 'risk_exceptions', ['tenant_id'])
|
|
||||||
op.create_index('ix_risk_exceptions_applicant_id', 'risk_exceptions', ['applicant_id'])
|
|
||||||
op.create_index('ix_risk_exceptions_target_id', 'risk_exceptions', ['target_id'])
|
|
||||||
op.create_index('ix_risk_exceptions_status', 'risk_exceptions', ['status'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# 删除表
|
# 删除表
|
||||||
op.drop_table('risk_exceptions')
|
|
||||||
op.drop_table('competitors')
|
op.drop_table('competitors')
|
||||||
op.drop_table('whitelist_items')
|
op.drop_table('whitelist_items')
|
||||||
op.drop_table('forbidden_words')
|
op.drop_table('forbidden_words')
|
||||||
op.drop_table('manual_tasks')
|
|
||||||
op.drop_table('review_tasks')
|
op.drop_table('review_tasks')
|
||||||
op.drop_table('ai_configs')
|
op.drop_table('ai_configs')
|
||||||
op.drop_table('tenants')
|
op.drop_table('tenants')
|
||||||
|
|
||||||
# 删除枚举类型
|
# 删除枚举类型
|
||||||
op.execute('DROP TYPE IF EXISTS risk_exception_status_enum')
|
|
||||||
op.execute('DROP TYPE IF EXISTS risk_target_type_enum')
|
|
||||||
op.execute('DROP TYPE IF EXISTS task_status_enum')
|
op.execute('DROP TYPE IF EXISTS task_status_enum')
|
||||||
op.execute('DROP TYPE IF EXISTS platform_enum')
|
op.execute('DROP TYPE IF EXISTS platform_enum')
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
一致性指标 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,
|
|
||||||
)
|
|
||||||
@ -279,8 +279,7 @@ async def search_agencies(
|
|||||||
)
|
)
|
||||||
agencies = list(result.scalars().all())
|
agencies = list(result.scalars().all())
|
||||||
|
|
||||||
return {
|
items = [
|
||||||
"items": [
|
|
||||||
AgencySummary(
|
AgencySummary(
|
||||||
id=a.id,
|
id=a.id,
|
||||||
name=a.name,
|
name=a.name,
|
||||||
@ -290,7 +289,7 @@ async def search_agencies(
|
|||||||
).model_dump()
|
).model_dump()
|
||||||
for a in agencies
|
for a in agencies
|
||||||
]
|
]
|
||||||
}
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search/creators")
|
@router.get("/search/creators")
|
||||||
@ -307,8 +306,7 @@ async def search_creators(
|
|||||||
)
|
)
|
||||||
creators = list(result.scalars().all())
|
creators = list(result.scalars().all())
|
||||||
|
|
||||||
return {
|
items = [
|
||||||
"items": [
|
|
||||||
CreatorSummary(
|
CreatorSummary(
|
||||||
id=c.id,
|
id=c.id,
|
||||||
name=c.name,
|
name=c.name,
|
||||||
@ -319,4 +317,4 @@ async def search_creators(
|
|||||||
).model_dump()
|
).model_dump()
|
||||||
for c in creators
|
for c in creators
|
||||||
]
|
]
|
||||||
}
|
return {"items": items, "total": len(items)}
|
||||||
|
|||||||
@ -1,226 +0,0 @@
|
|||||||
"""
|
|
||||||
特例审批 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)
|
|
||||||
@ -21,14 +21,12 @@ from app.models import (
|
|||||||
Brief,
|
Brief,
|
||||||
# AI 配置
|
# AI 配置
|
||||||
AIConfig,
|
AIConfig,
|
||||||
# 审核(旧模型)
|
# 审核
|
||||||
ReviewTask,
|
ReviewTask,
|
||||||
ManualTask,
|
|
||||||
# 规则
|
# 规则
|
||||||
ForbiddenWord,
|
ForbiddenWord,
|
||||||
WhitelistItem,
|
WhitelistItem,
|
||||||
Competitor,
|
Competitor,
|
||||||
RiskException,
|
|
||||||
# 兼容
|
# 兼容
|
||||||
Tenant,
|
Tenant,
|
||||||
)
|
)
|
||||||
@ -97,12 +95,10 @@ __all__ = [
|
|||||||
"AIConfig",
|
"AIConfig",
|
||||||
# 审核
|
# 审核
|
||||||
"ReviewTask",
|
"ReviewTask",
|
||||||
"ManualTask",
|
|
||||||
# 规则
|
# 规则
|
||||||
"ForbiddenWord",
|
"ForbiddenWord",
|
||||||
"WhitelistItem",
|
"WhitelistItem",
|
||||||
"Competitor",
|
"Competitor",
|
||||||
"RiskException",
|
|
||||||
# 兼容
|
# 兼容
|
||||||
"Tenant",
|
"Tenant",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, risk_exceptions, metrics, sse, projects, briefs, organizations, dashboard
|
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard
|
||||||
|
|
||||||
# 创建应用
|
# 创建应用
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -31,8 +31,6 @@ app.include_router(videos.router, prefix="/api/v1")
|
|||||||
app.include_router(tasks.router, prefix="/api/v1")
|
app.include_router(tasks.router, prefix="/api/v1")
|
||||||
app.include_router(rules.router, prefix="/api/v1")
|
app.include_router(rules.router, prefix="/api/v1")
|
||||||
app.include_router(ai_config.router, prefix="/api/v1")
|
app.include_router(ai_config.router, prefix="/api/v1")
|
||||||
app.include_router(risk_exceptions.router, prefix="/api/v1")
|
|
||||||
app.include_router(metrics.router, prefix="/api/v1")
|
|
||||||
app.include_router(sse.router, prefix="/api/v1")
|
app.include_router(sse.router, prefix="/api/v1")
|
||||||
app.include_router(projects.router, prefix="/api/v1")
|
app.include_router(projects.router, prefix="/api/v1")
|
||||||
app.include_router(briefs.router, prefix="/api/v1")
|
app.include_router(briefs.router, prefix="/api/v1")
|
||||||
|
|||||||
@ -9,10 +9,8 @@ from app.models.project import Project, project_agency_association
|
|||||||
from app.models.task import Task, TaskStage, TaskStatus
|
from app.models.task import Task, TaskStage, TaskStatus
|
||||||
from app.models.brief import Brief
|
from app.models.brief import Brief
|
||||||
from app.models.ai_config import AIConfig
|
from app.models.ai_config import AIConfig
|
||||||
from app.models.review import ReviewTask, ManualTask, Platform
|
from app.models.review import ReviewTask, Platform
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||||
from app.models.risk_exception import RiskException
|
|
||||||
|
|
||||||
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
@ -37,15 +35,13 @@ __all__ = [
|
|||||||
"Brief",
|
"Brief",
|
||||||
# AI 配置
|
# AI 配置
|
||||||
"AIConfig",
|
"AIConfig",
|
||||||
# 审核(旧模型,保留兼容)
|
# 审核
|
||||||
"ReviewTask",
|
"ReviewTask",
|
||||||
"ManualTask",
|
|
||||||
"Platform",
|
"Platform",
|
||||||
# 规则
|
# 规则
|
||||||
"ForbiddenWord",
|
"ForbiddenWord",
|
||||||
"WhitelistItem",
|
"WhitelistItem",
|
||||||
"Competitor",
|
"Competitor",
|
||||||
"RiskException",
|
|
||||||
# 兼容
|
# 兼容
|
||||||
"Tenant",
|
"Tenant",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -85,80 +85,5 @@ class ReviewTask(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 关联
|
# 关联
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks")
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks")
|
||||||
manual_task: Mapped[Optional["ManualTask"]] = relationship(
|
|
||||||
"ManualTask",
|
|
||||||
back_populates="review_task",
|
|
||||||
uselist=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<ReviewTask(id={self.id}, status={self.status})>"
|
return f"<ReviewTask(id={self.id}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
class ManualTask(Base, TimestampMixin):
|
|
||||||
"""人工审核任务表"""
|
|
||||||
__tablename__ = "manual_tasks"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
||||||
tenant_id: Mapped[str] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
review_task_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
ForeignKey("review_tasks.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 视频信息 (冗余存储,即使关联的 review_task 被删除也能查看)
|
|
||||||
video_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
|
||||||
video_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
platform: Mapped[Platform] = mapped_column(
|
|
||||||
SQLEnum(Platform, name="platform_enum", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
||||||
|
|
||||||
# 脚本信息
|
|
||||||
script_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
script_file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
|
||||||
script_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 任务状态
|
|
||||||
status: Mapped[TaskStatus] = mapped_column(
|
|
||||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
|
||||||
default=TaskStatus.PENDING,
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 审批结果
|
|
||||||
approve_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
reject_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
reject_violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
|
||||||
|
|
||||||
# 审批人
|
|
||||||
reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
||||||
reviewed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 关联
|
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="manual_tasks")
|
|
||||||
review_task: Mapped[Optional["ReviewTask"]] = relationship(
|
|
||||||
"ReviewTask",
|
|
||||||
back_populates="manual_task",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<ManualTask(id={self.id}, status={self.status})>"
|
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
"""
|
|
||||||
特例审批模型
|
|
||||||
"""
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime, Enum as SQLEnum
|
|
||||||
from app.models.types import JSONType
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.tenant import Tenant
|
|
||||||
|
|
||||||
|
|
||||||
class RiskTargetType(str, enum.Enum):
|
|
||||||
"""特例目标类型"""
|
|
||||||
INFLUENCER = "influencer"
|
|
||||||
ORDER = "order"
|
|
||||||
CONTENT = "content"
|
|
||||||
|
|
||||||
|
|
||||||
class RiskExceptionStatus(str, enum.Enum):
|
|
||||||
"""特例审批状态"""
|
|
||||||
PENDING = "pending"
|
|
||||||
APPROVED = "approved"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
EXPIRED = "expired"
|
|
||||||
REVOKED = "revoked"
|
|
||||||
|
|
||||||
|
|
||||||
class RiskException(Base, TimestampMixin):
|
|
||||||
"""特例审批表"""
|
|
||||||
__tablename__ = "risk_exceptions"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
||||||
tenant_id: Mapped[str] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 申请信息
|
|
||||||
applicant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
||||||
apply_time: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 目标信息
|
|
||||||
target_type: Mapped[RiskTargetType] = mapped_column(
|
|
||||||
SQLEnum(RiskTargetType, name="risk_target_type_enum"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
target_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
|
||||||
risk_rule_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
|
|
||||||
# 状态
|
|
||||||
status: Mapped[RiskExceptionStatus] = mapped_column(
|
|
||||||
SQLEnum(RiskExceptionStatus, name="risk_exception_status_enum"),
|
|
||||||
default=RiskExceptionStatus.PENDING,
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 有效期
|
|
||||||
valid_start_time: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
valid_end_time: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 申请原因
|
|
||||||
reason_category: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
attachment_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
|
||||||
|
|
||||||
# 审批信息
|
|
||||||
current_approver_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# 审批流转日志 (JSON 数组)
|
|
||||||
# [{"approver_id": "...", "action": "approve/reject", "comment": "...", "timestamp": "..."}]
|
|
||||||
approval_chain_log: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
|
||||||
|
|
||||||
# 驳回信息
|
|
||||||
auto_rejected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
# 最近状态变更时间
|
|
||||||
last_status_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 关联
|
|
||||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="risk_exceptions")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<RiskException(id={self.id}, status={self.status})>"
|
|
||||||
@ -9,9 +9,8 @@ from app.models.base import Base, TimestampMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.ai_config import AIConfig
|
from app.models.ai_config import AIConfig
|
||||||
from app.models.review import ReviewTask, ManualTask
|
from app.models.review import ReviewTask
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||||
from app.models.risk_exception import RiskException
|
|
||||||
|
|
||||||
|
|
||||||
class Tenant(Base, TimestampMixin):
|
class Tenant(Base, TimestampMixin):
|
||||||
@ -34,11 +33,6 @@ class Tenant(Base, TimestampMixin):
|
|||||||
back_populates="tenant",
|
back_populates="tenant",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
manual_tasks: Mapped[list["ManualTask"]] = relationship(
|
|
||||||
"ManualTask",
|
|
||||||
back_populates="tenant",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
forbidden_words: Mapped[list["ForbiddenWord"]] = relationship(
|
forbidden_words: Mapped[list["ForbiddenWord"]] = relationship(
|
||||||
"ForbiddenWord",
|
"ForbiddenWord",
|
||||||
back_populates="tenant",
|
back_populates="tenant",
|
||||||
@ -54,11 +48,5 @@ class Tenant(Base, TimestampMixin):
|
|||||||
back_populates="tenant",
|
back_populates="tenant",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
risk_exceptions: Mapped[list["RiskException"]] = relationship(
|
|
||||||
"RiskException",
|
|
||||||
back_populates="tenant",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||||
|
|||||||
@ -171,142 +171,3 @@ class VideoReviewResultResponse(BaseModel):
|
|||||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||||
|
|
||||||
|
|
||||||
# ==================== 一致性指标 ====================
|
|
||||||
|
|
||||||
class ConsistencyWindow(str, Enum):
|
|
||||||
"""一致性指标计算周期"""
|
|
||||||
ROLLING_30D = "rolling_30d"
|
|
||||||
SNAPSHOT_WEEK = "snapshot_week"
|
|
||||||
SNAPSHOT_MONTH = "snapshot_month"
|
|
||||||
|
|
||||||
|
|
||||||
class RuleConsistencyMetric(BaseModel):
|
|
||||||
"""按规则类型的指标"""
|
|
||||||
rule_type: ViolationType = Field(..., description="规则类型")
|
|
||||||
total_reviews: int = Field(..., ge=0, description="总审核数")
|
|
||||||
violation_count: int = Field(..., ge=0, description="违规数")
|
|
||||||
violation_rate: float = Field(..., ge=0, le=1, description="违规率(0-1)")
|
|
||||||
|
|
||||||
|
|
||||||
class ConsistencyMetricsResponse(BaseModel):
|
|
||||||
"""一致性指标响应"""
|
|
||||||
influencer_id: str = Field(..., description="达人 ID")
|
|
||||||
window: ConsistencyWindow = Field(..., description="计算周期")
|
|
||||||
period_start: datetime = Field(..., description="周期起始时间")
|
|
||||||
period_end: datetime = Field(..., description="周期结束时间")
|
|
||||||
metrics: list[RuleConsistencyMetric] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 特例审批(风控豁免) ====================
|
|
||||||
|
|
||||||
class RiskTargetType(str, Enum):
|
|
||||||
"""特例目标类型"""
|
|
||||||
INFLUENCER = "influencer"
|
|
||||||
ORDER = "order"
|
|
||||||
CONTENT = "content"
|
|
||||||
|
|
||||||
|
|
||||||
class RiskExceptionStatus(str, Enum):
|
|
||||||
"""特例审批状态"""
|
|
||||||
PENDING = "pending"
|
|
||||||
APPROVED = "approved"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
EXPIRED = "expired"
|
|
||||||
REVOKED = "revoked"
|
|
||||||
|
|
||||||
|
|
||||||
class RiskExceptionCreateRequest(BaseModel):
|
|
||||||
"""创建特例请求"""
|
|
||||||
applicant_id: str = Field(..., description="申请人")
|
|
||||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
|
||||||
target_id: str = Field(..., description="目标 ID")
|
|
||||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
|
||||||
reason_category: str = Field(..., description="原因分类")
|
|
||||||
justification: str = Field(..., min_length=1, description="详细理由")
|
|
||||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
|
||||||
current_approver_id: str = Field(..., description="当前审批人")
|
|
||||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
|
||||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
|
||||||
|
|
||||||
|
|
||||||
class RiskExceptionRecord(BaseModel):
|
|
||||||
"""特例记录"""
|
|
||||||
record_id: str = Field(..., description="记录 ID")
|
|
||||||
applicant_id: str = Field(..., description="申请人")
|
|
||||||
apply_time: datetime = Field(..., description="申请时间")
|
|
||||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
|
||||||
target_id: str = Field(..., description="目标 ID")
|
|
||||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
|
||||||
status: RiskExceptionStatus = Field(..., description="状态")
|
|
||||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
|
||||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
|
||||||
reason_category: str = Field(..., description="原因分类")
|
|
||||||
justification: str = Field(..., description="详细理由")
|
|
||||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
|
||||||
current_approver_id: Optional[str] = Field(None, description="当前审批人")
|
|
||||||
approval_chain_log: list[dict] = Field(default_factory=list, description="审批流转日志")
|
|
||||||
auto_rejected: bool = Field(default=False, description="是否超时自动拒绝")
|
|
||||||
rejection_reason: Optional[str] = Field(None, description="驳回原因")
|
|
||||||
last_status_at: Optional[datetime] = Field(None, description="最近状态变更时间")
|
|
||||||
|
|
||||||
|
|
||||||
class RiskExceptionDecisionRequest(BaseModel):
|
|
||||||
"""特例审批决策请求"""
|
|
||||||
approver_id: str = Field(..., description="审批人")
|
|
||||||
comment: Optional[str] = Field(None, description="审批备注")
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 审核任务 ====================
|
|
||||||
|
|
||||||
class TaskCreateRequest(BaseModel):
|
|
||||||
"""创建任务请求"""
|
|
||||||
platform: Platform = Field(..., description="投放平台")
|
|
||||||
creator_id: str = Field(..., description="达人 ID")
|
|
||||||
video_url: Optional[HttpUrl] = Field(None, description="视频 URL")
|
|
||||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
|
||||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskScriptUploadRequest(BaseModel):
|
|
||||||
"""上传脚本请求"""
|
|
||||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
|
||||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskVideoUploadRequest(BaseModel):
|
|
||||||
"""上传视频请求"""
|
|
||||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
|
||||||
"""任务响应"""
|
|
||||||
task_id: str = Field(..., description="任务 ID")
|
|
||||||
video_url: Optional[str] = Field(None, description="视频 URL")
|
|
||||||
script_content: Optional[str] = Field(None, description="脚本内容")
|
|
||||||
script_file_url: Optional[str] = Field(None, description="脚本文档 URL")
|
|
||||||
has_script: bool = Field(..., description="是否已上传脚本")
|
|
||||||
has_video: bool = Field(..., description="是否已上传视频")
|
|
||||||
platform: Platform = Field(..., description="投放平台")
|
|
||||||
creator_id: str = Field(..., description="达人 ID")
|
|
||||||
status: TaskStatus = Field(..., description="任务状态")
|
|
||||||
created_at: str = Field(..., description="创建时间")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskListResponse(BaseModel):
|
|
||||||
"""任务列表响应"""
|
|
||||||
items: list[TaskResponse] = Field(default_factory=list)
|
|
||||||
total: int = Field(..., description="总数")
|
|
||||||
page: int = Field(..., description="当前页")
|
|
||||||
page_size: int = Field(..., description="每页数量")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskApproveRequest(BaseModel):
|
|
||||||
"""通过任务请求"""
|
|
||||||
comment: Optional[str] = Field(None, description="备注")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskRejectRequest(BaseModel):
|
|
||||||
"""驳回任务请求"""
|
|
||||||
reason: str = Field(..., min_length=1, description="驳回原因")
|
|
||||||
violations: list[str] = Field(default_factory=list, description="违规类型列表")
|
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
"""
|
|
||||||
一致性指标 API 测试 (TDD - 红色阶段)
|
|
||||||
双轨制: Rolling 30 Days + Snapshot 周/月
|
|
||||||
维度: Influencer + Rule Type
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from app.schemas.review import ConsistencyMetricsResponse, ConsistencyWindow, ViolationType
|
|
||||||
|
|
||||||
|
|
||||||
class TestConsistencyMetrics:
|
|
||||||
"""一致性指标查询"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_requires_influencer_id(self, client: AsyncClient):
|
|
||||||
"""缺少 influencer_id 返回 422"""
|
|
||||||
response = await client.get("/api/v1/metrics/consistency?window=rolling_30d")
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rolling_30d_returns_metrics(self, client: AsyncClient, influencer_id: str):
|
|
||||||
"""Rolling 30 Days 返回指标"""
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=rolling_30d"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
|
||||||
assert parsed.influencer_id == influencer_id
|
|
||||||
assert parsed.window == ConsistencyWindow.ROLLING_30D
|
|
||||||
assert parsed.period_start < parsed.period_end
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_snapshot_week_returns_metrics(self, client: AsyncClient, influencer_id: str):
|
|
||||||
"""Snapshot 周度返回指标"""
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=snapshot_week"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
|
||||||
assert parsed.window == ConsistencyWindow.SNAPSHOT_WEEK
|
|
||||||
assert parsed.period_start < parsed.period_end
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_filter_by_rule_type(self, client: AsyncClient, influencer_id: str):
|
|
||||||
"""按规则类型筛选"""
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}"
|
|
||||||
"&window=rolling_30d&rule_type=forbidden_word"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
parsed = ConsistencyMetricsResponse.model_validate(response.json())
|
|
||||||
if parsed.metrics:
|
|
||||||
assert all(m.rule_type == ViolationType.FORBIDDEN_WORD for m in parsed.metrics)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_window_returns_422(self, client: AsyncClient, influencer_id: str):
|
|
||||||
"""非法窗口返回 422"""
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/v1/metrics/consistency?influencer_id={influencer_id}&window=invalid_window"
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
"""
|
|
||||||
审核任务 API 测试 (TDD - 红色阶段)
|
|
||||||
测试覆盖: 创建任务、查询任务、更新任务状态
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from app.schemas.review import TaskResponse, TaskListResponse, TaskStatus
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateTask:
|
|
||||||
"""创建审核任务"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_returns_201(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""创建任务返回 201"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"video_url": video_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_returns_task_id(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""创建任务返回任务 ID"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"video_url": video_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.task_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_initial_status_pending(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""创建任务初始状态为 pending"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"video_url": video_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.status == TaskStatus.PENDING
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_validates_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""创建任务校验平台参数"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "invalid_platform",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"video_url": video_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_validates_video_url(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
|
||||||
"""创建任务校验视频 URL"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"video_url": "not-a-url",
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_allows_missing_video(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
|
||||||
"""创建任务允许暂不上传视频"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.has_video is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_task_with_script_content(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
|
||||||
"""创建任务可携带脚本内容"""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"script_content": "脚本内容示例",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.has_script is True
|
|
||||||
assert parsed.script_content == "脚本内容示例"
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTask:
|
|
||||||
"""查询审核任务"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""查询存在的任务返回 200"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
# 先创建任务
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
"video_url": video_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
# 查询任务
|
|
||||||
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_task_returns_task_details(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""查询任务返回完整信息"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
|
|
||||||
assert parsed.task_id == task_id
|
|
||||||
assert parsed.video_url == video_url
|
|
||||||
assert parsed.platform.value == "douyin"
|
|
||||||
assert parsed.creator_id == creator_id
|
|
||||||
assert parsed.has_video is True
|
|
||||||
assert parsed.created_at
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_nonexistent_task_returns_404(self, client: AsyncClient, tenant_id: str):
|
|
||||||
"""查询不存在的任务返回 404"""
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/tasks/nonexistent-task-id",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestListTasks:
|
|
||||||
"""任务列表查询"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_tasks_returns_200(self, client: AsyncClient, tenant_id: str):
|
|
||||||
"""查询任务列表返回 200"""
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_tasks_returns_array(self, client: AsyncClient, tenant_id: str):
|
|
||||||
"""查询任务列表返回数组"""
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskListResponse.model_validate(data)
|
|
||||||
assert isinstance(parsed.items, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_tasks_pagination(self, client: AsyncClient, tenant_id: str):
|
|
||||||
"""任务列表支持分页"""
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/tasks?page=1&page_size=10",
|
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskListResponse.model_validate(data)
|
|
||||||
assert parsed.page == 1
|
|
||||||
assert parsed.page_size == 10
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_tasks_filter_by_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""任务列表支持按状态筛选"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
response = await client.get("/api/v1/tasks?status=pending", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskListResponse.model_validate(data)
|
|
||||||
assert any(item.task_id == task_id for item in parsed.items)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_tasks_filter_by_platform(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""任务列表支持按平台筛选"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
response = await client.get("/api/v1/tasks?platform=douyin", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskListResponse.model_validate(data)
|
|
||||||
assert any(item.task_id == task_id for item in parsed.items)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUploadTaskAssets:
|
|
||||||
"""任务脚本/视频上传"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_script_requires_payload(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
|
||||||
"""上传脚本必须提供内容或文件 URL"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/script",
|
|
||||||
headers=headers,
|
|
||||||
json={},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_script_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str):
|
|
||||||
"""上传脚本更新任务内容"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/script",
|
|
||||||
headers=headers,
|
|
||||||
json={"script_content": "更新后的脚本"},
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.has_script is True
|
|
||||||
assert parsed.script_content == "更新后的脚本"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_video_updates_task(self, client: AsyncClient, tenant_id: str, creator_id: str, video_url: str):
|
|
||||||
"""上传视频更新任务视频 URL"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/video",
|
|
||||||
headers=headers,
|
|
||||||
json={"video_url": video_url},
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
parsed = TaskResponse.model_validate(data)
|
|
||||||
assert parsed.has_video is True
|
|
||||||
assert parsed.video_url == video_url
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateTaskStatus:
|
|
||||||
"""更新任务状态"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""通过任务返回 200"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
# 创建任务
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
# 通过任务
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/approve",
|
|
||||||
headers=headers,
|
|
||||||
json={"comment": "审核通过"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_task_updates_status(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""通过任务更新状态为 approved"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/approve",
|
|
||||||
headers=headers,
|
|
||||||
json={"comment": "审核通过"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 验证状态
|
|
||||||
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
|
||||||
parsed = TaskResponse.model_validate(get_resp.json())
|
|
||||||
assert parsed.status == TaskStatus.APPROVED
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reject_task_returns_200(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""驳回任务返回 200"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/reject",
|
|
||||||
headers=headers,
|
|
||||||
json={"reason": "违规内容", "violations": ["forbidden_word"]}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
get_resp = await client.get(f"/api/v1/tasks/{task_id}", headers=headers)
|
|
||||||
parsed = TaskResponse.model_validate(get_resp.json())
|
|
||||||
assert parsed.status == TaskStatus.REJECTED
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reject_task_requires_reason(self, client: AsyncClient, tenant_id: str, video_url: str, creator_id: str):
|
|
||||||
"""驳回任务必须提供原因"""
|
|
||||||
headers = {"X-Tenant-ID": tenant_id}
|
|
||||||
create_resp = await client.post(
|
|
||||||
"/api/v1/tasks",
|
|
||||||
headers=headers,
|
|
||||||
json={
|
|
||||||
"video_url": video_url,
|
|
||||||
"platform": "douyin",
|
|
||||||
"creator_id": creator_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id = create_resp.json()["task_id"]
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/tasks/{task_id}/reject",
|
|
||||||
headers=headers,
|
|
||||||
json={}
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
@ -162,6 +162,14 @@ function LoginForm() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* 注册链接 */}
|
||||||
|
<p className="text-center text-sm text-text-secondary">
|
||||||
|
还没有账号?{' '}
|
||||||
|
<Link href="/register" className="text-accent-indigo hover:underline font-medium">
|
||||||
|
立即注册
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Demo 登录 */}
|
{/* Demo 登录 */}
|
||||||
<div className="pt-6 border-t border-border-subtle">
|
<div className="pt-6 border-t border-border-subtle">
|
||||||
<p className="text-sm text-text-tertiary text-center mb-4">快速体验(Demo 账号)</p>
|
<p className="text-sm text-text-tertiary text-center mb-4">快速体验(Demo 账号)</p>
|
||||||
|
|||||||
233
frontend/app/register/page.tsx
Normal file
233
frontend/app/register/page.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { UserRole } from '@/lib/api'
|
||||||
|
|
||||||
|
const roleOptions: { value: UserRole; label: string; desc: string }[] = [
|
||||||
|
{ value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' },
|
||||||
|
{ value: 'agency', label: '代理商', desc: '管理达人、分配任务、审核内容' },
|
||||||
|
{ value: 'creator', label: '达人', desc: '上传脚本和视频、查看审核结果' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { register } = useAuth()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [role, setRole] = useState<UserRole>('creator')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!email && !phone) {
|
||||||
|
setError('请填写邮箱或手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('两次密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
const result = await register({
|
||||||
|
name: name.trim(),
|
||||||
|
email: email || undefined,
|
||||||
|
phone: phone || undefined,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
switch (role) {
|
||||||
|
case 'creator':
|
||||||
|
router.push('/creator')
|
||||||
|
break
|
||||||
|
case 'agency':
|
||||||
|
router.push('/agency')
|
||||||
|
break
|
||||||
|
case 'brand':
|
||||||
|
router.push('/brand')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || '注册失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-page flex flex-col items-center justify-center px-6 py-12">
|
||||||
|
<div className="w-full max-w-sm space-y-8">
|
||||||
|
{/* 返回 */}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)]">
|
||||||
|
<ShieldCheck className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-2xl font-bold text-text-primary">注册账号</span>
|
||||||
|
<p className="text-sm text-text-secondary">加入秒思智能审核平台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 注册表单 */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-accent-coral/10 text-accent-coral rounded-lg text-sm">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 角色选择 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">选择角色</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{roleOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole(opt.value)}
|
||||||
|
className={`p-3 rounded-xl border text-center transition-all ${
|
||||||
|
role === opt.value
|
||||||
|
? 'border-accent-indigo bg-accent-indigo/10 text-accent-indigo'
|
||||||
|
: 'border-border-subtle bg-bg-card text-text-secondary hover:bg-bg-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{opt.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{roleOptions.find((o) => o.value === role)?.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户名 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">用户名</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">邮箱</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 手机号 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">
|
||||||
|
手机号 <span className="text-text-tertiary font-normal">(选填)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="至少 6 位密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认密码 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-text-primary">确认密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold text-base shadow-[0px_8px_24px_-4px_rgba(99,102,241,0.4)] hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? '注册中...' : '注册'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 底部链接 */}
|
||||||
|
<p className="text-center text-sm text-text-secondary">
|
||||||
|
已有账号?{' '}
|
||||||
|
<Link href="/login" className="text-accent-indigo hover:underline font-medium">
|
||||||
|
立即登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { api } from '@/lib/api'
|
|||||||
import type {
|
import type {
|
||||||
VideoReviewRequest,
|
VideoReviewRequest,
|
||||||
ReviewTask,
|
ReviewTask,
|
||||||
TaskStatus,
|
ReviewTaskStatus,
|
||||||
} from '@/types/review'
|
} from '@/types/review'
|
||||||
|
|
||||||
interface UseReviewOptions {
|
interface UseReviewOptions {
|
||||||
@ -36,11 +36,11 @@ export function useReview(options: UseReviewOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
const response = await api.submitVideoReview(data)
|
const response = await api.submitVideoReview(data)
|
||||||
setTask({
|
setTask({
|
||||||
reviewId: response.reviewId,
|
review_id: response.review_id,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
createdAt: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
return response.reviewId
|
return response.review_id
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err : new Error('提交失败')
|
const error = err instanceof Error ? err : new Error('提交失败')
|
||||||
setError(error)
|
setError(error)
|
||||||
@ -59,11 +59,11 @@ export function useReview(options: UseReviewOptions = {}) {
|
|||||||
const progress = await api.getReviewProgress(reviewId)
|
const progress = await api.getReviewProgress(reviewId)
|
||||||
setTask((prev) => ({
|
setTask((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
reviewId: progress.reviewId,
|
review_id: progress.review_id,
|
||||||
status: progress.status,
|
status: progress.status,
|
||||||
progress: progress.progress,
|
progress: progress.progress,
|
||||||
currentStep: progress.currentStep,
|
current_step: progress.current_step,
|
||||||
createdAt: prev?.createdAt || new Date().toISOString(),
|
created_at: prev?.created_at || new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
return progress
|
return progress
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -80,14 +80,14 @@ export function useReview(options: UseReviewOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.getReviewResult(reviewId)
|
const result = await api.getReviewResult(reviewId)
|
||||||
const updatedTask: ReviewTask = {
|
const updatedTask: ReviewTask = {
|
||||||
reviewId: result.reviewId,
|
review_id: result.review_id,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
score: result.score,
|
score: result.score,
|
||||||
summary: result.summary,
|
summary: result.summary,
|
||||||
violations: result.violations,
|
violations: result.violations,
|
||||||
softWarnings: result.softWarnings,
|
soft_warnings: result.soft_warnings,
|
||||||
createdAt: task?.createdAt || new Date().toISOString(),
|
created_at: task?.created_at || new Date().toISOString(),
|
||||||
completedAt: new Date().toISOString(),
|
completed_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
setTask(updatedTask)
|
setTask(updatedTask)
|
||||||
return updatedTask
|
return updatedTask
|
||||||
@ -96,7 +96,7 @@ export function useReview(options: UseReviewOptions = {}) {
|
|||||||
setError(error)
|
setError(error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [task?.createdAt])
|
}, [task?.created_at])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除轮询定时器
|
* 清除轮询定时器
|
||||||
@ -203,13 +203,13 @@ export function useReviewResult(reviewId: string | null) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.getReviewResult(reviewId)
|
const result = await api.getReviewResult(reviewId)
|
||||||
setTask({
|
setTask({
|
||||||
reviewId: result.reviewId,
|
review_id: result.review_id,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
score: result.score,
|
score: result.score,
|
||||||
summary: result.summary,
|
summary: result.summary,
|
||||||
violations: result.violations,
|
violations: result.violations,
|
||||||
softWarnings: result.softWarnings,
|
soft_warnings: result.soft_warnings,
|
||||||
createdAt: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err : new Error('查询失败'))
|
setError(err instanceof Error ? err : new Error('查询失败'))
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import type {
|
|||||||
VideoReviewResponse,
|
VideoReviewResponse,
|
||||||
ReviewProgressResponse,
|
ReviewProgressResponse,
|
||||||
ReviewResultResponse,
|
ReviewResultResponse,
|
||||||
|
ScriptReviewRequest,
|
||||||
|
ScriptReviewResponse,
|
||||||
} from '@/types/review'
|
} from '@/types/review'
|
||||||
import type {
|
import type {
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
@ -40,6 +42,29 @@ import type {
|
|||||||
AgencyDashboard,
|
AgencyDashboard,
|
||||||
BrandDashboard,
|
BrandDashboard,
|
||||||
} from '@/types/dashboard'
|
} from '@/types/dashboard'
|
||||||
|
import type {
|
||||||
|
ForbiddenWordCreate,
|
||||||
|
ForbiddenWordResponse,
|
||||||
|
ForbiddenWordListResponse,
|
||||||
|
WhitelistCreate,
|
||||||
|
WhitelistResponse,
|
||||||
|
WhitelistListResponse,
|
||||||
|
CompetitorCreate,
|
||||||
|
CompetitorResponse,
|
||||||
|
CompetitorListResponse,
|
||||||
|
PlatformRuleResponse,
|
||||||
|
PlatformListResponse,
|
||||||
|
RuleValidateRequest,
|
||||||
|
RuleValidateResponse,
|
||||||
|
} from '@/types/rules'
|
||||||
|
import type {
|
||||||
|
AIConfigUpdate,
|
||||||
|
AIConfigResponse,
|
||||||
|
GetModelsRequest,
|
||||||
|
ModelsListResponse,
|
||||||
|
TestConnectionRequest,
|
||||||
|
ConnectionTestResponse,
|
||||||
|
} from '@/types/ai-config'
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
||||||
const STORAGE_KEY_ACCESS = 'miaosi_access_token'
|
const STORAGE_KEY_ACCESS = 'miaosi_access_token'
|
||||||
@ -67,7 +92,8 @@ export interface User {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email?: string
|
email?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
password: string
|
password?: string
|
||||||
|
sms_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
@ -102,6 +128,14 @@ export interface UploadPolicyResponse {
|
|||||||
max_size_mb: number
|
max_size_mb: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileUploadedResponse {
|
||||||
|
url: string
|
||||||
|
file_key: string
|
||||||
|
file_name: string
|
||||||
|
file_size: number
|
||||||
|
file_type: string
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Token 管理 ====================
|
// ==================== Token 管理 ====================
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
@ -283,8 +317,8 @@ class ApiClient {
|
|||||||
/**
|
/**
|
||||||
* 文件上传完成回调
|
* 文件上传完成回调
|
||||||
*/
|
*/
|
||||||
async fileUploaded(fileKey: string, fileName: string, fileSize: number, fileType: string): Promise<{ url: string }> {
|
async fileUploaded(fileKey: string, fileName: string, fileSize: number, fileType: string): Promise<FileUploadedResponse> {
|
||||||
const response = await this.client.post<{ url: string }>('/upload/complete', {
|
const response = await this.client.post<FileUploadedResponse>('/upload/complete', {
|
||||||
file_key: fileKey,
|
file_key: fileKey,
|
||||||
file_name: fileName,
|
file_name: fileName,
|
||||||
file_size: fileSize,
|
file_size: fileSize,
|
||||||
@ -299,12 +333,7 @@ class ApiClient {
|
|||||||
* 提交视频审核
|
* 提交视频审核
|
||||||
*/
|
*/
|
||||||
async submitVideoReview(data: VideoReviewRequest): Promise<VideoReviewResponse> {
|
async submitVideoReview(data: VideoReviewRequest): Promise<VideoReviewResponse> {
|
||||||
const response = await this.client.post<VideoReviewResponse>('/videos/review', {
|
const response = await this.client.post<VideoReviewResponse>('/videos/review', data)
|
||||||
video_url: data.videoUrl,
|
|
||||||
platform: data.platform,
|
|
||||||
brand_id: data.brandId,
|
|
||||||
creator_id: data.creatorId,
|
|
||||||
})
|
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -603,6 +632,144 @@ class ApiClient {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 脚本预审 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脚本预审(AI 审核)
|
||||||
|
*/
|
||||||
|
async reviewScriptContent(data: ScriptReviewRequest): Promise<ScriptReviewResponse> {
|
||||||
|
const response = await this.client.post<ScriptReviewResponse>('/scripts/review', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 规则管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询违禁词列表
|
||||||
|
*/
|
||||||
|
async listForbiddenWords(category?: string): Promise<ForbiddenWordListResponse> {
|
||||||
|
const response = await this.client.get<ForbiddenWordListResponse>('/rules/forbidden-words', {
|
||||||
|
params: category ? { category } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加违禁词
|
||||||
|
*/
|
||||||
|
async addForbiddenWord(data: ForbiddenWordCreate): Promise<ForbiddenWordResponse> {
|
||||||
|
const response = await this.client.post<ForbiddenWordResponse>('/rules/forbidden-words', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除违禁词
|
||||||
|
*/
|
||||||
|
async deleteForbiddenWord(wordId: string): Promise<void> {
|
||||||
|
await this.client.delete(`/rules/forbidden-words/${wordId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询白名单
|
||||||
|
*/
|
||||||
|
async listWhitelist(brandId?: string): Promise<WhitelistListResponse> {
|
||||||
|
const response = await this.client.get<WhitelistListResponse>('/rules/whitelist', {
|
||||||
|
params: brandId ? { brand_id: brandId } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加白名单
|
||||||
|
*/
|
||||||
|
async addToWhitelist(data: WhitelistCreate): Promise<WhitelistResponse> {
|
||||||
|
const response = await this.client.post<WhitelistResponse>('/rules/whitelist', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询竞品列表
|
||||||
|
*/
|
||||||
|
async listCompetitors(brandId?: string): Promise<CompetitorListResponse> {
|
||||||
|
const response = await this.client.get<CompetitorListResponse>('/rules/competitors', {
|
||||||
|
params: brandId ? { brand_id: brandId } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加竞品
|
||||||
|
*/
|
||||||
|
async addCompetitor(data: CompetitorCreate): Promise<CompetitorResponse> {
|
||||||
|
const response = await this.client.post<CompetitorResponse>('/rules/competitors', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除竞品
|
||||||
|
*/
|
||||||
|
async deleteCompetitor(competitorId: string): Promise<void> {
|
||||||
|
await this.client.delete(`/rules/competitors/${competitorId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有平台规则
|
||||||
|
*/
|
||||||
|
async listPlatformRules(): Promise<PlatformListResponse> {
|
||||||
|
const response = await this.client.get<PlatformListResponse>('/rules/platforms')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定平台规则
|
||||||
|
*/
|
||||||
|
async getPlatformRules(platform: string): Promise<PlatformRuleResponse> {
|
||||||
|
const response = await this.client.get<PlatformRuleResponse>(`/rules/platforms/${platform}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则冲突检测
|
||||||
|
*/
|
||||||
|
async validateRules(data: RuleValidateRequest): Promise<RuleValidateResponse> {
|
||||||
|
const response = await this.client.post<RuleValidateResponse>('/rules/validate', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AI 配置 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AI 配置
|
||||||
|
*/
|
||||||
|
async getAIConfig(): Promise<AIConfigResponse> {
|
||||||
|
const response = await this.client.get<AIConfigResponse>('/ai-config')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 AI 配置
|
||||||
|
*/
|
||||||
|
async updateAIConfig(data: AIConfigUpdate): Promise<AIConfigResponse> {
|
||||||
|
const response = await this.client.put<AIConfigResponse>('/ai-config', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用模型列表
|
||||||
|
*/
|
||||||
|
async getAIModels(data: GetModelsRequest): Promise<ModelsListResponse> {
|
||||||
|
const response = await this.client.post<ModelsListResponse>('/ai-config/models', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 AI 连接
|
||||||
|
*/
|
||||||
|
async testAIConnection(data: TestConnectionRequest): Promise<ConnectionTestResponse> {
|
||||||
|
const response = await this.client.post<ConnectionTestResponse>('/ai-config/test', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 健康检查 ====================
|
// ==================== 健康检查 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,8 +4,6 @@ export const platformOptions = [
|
|||||||
{ id: 'xiaohongshu', name: '小红书', icon: '📕', bgColor: 'bg-[#fe2c55]/15', textColor: 'text-[#fe2c55]', borderColor: 'border-[#fe2c55]/30' },
|
{ id: 'xiaohongshu', name: '小红书', icon: '📕', bgColor: 'bg-[#fe2c55]/15', textColor: 'text-[#fe2c55]', borderColor: 'border-[#fe2c55]/30' },
|
||||||
{ id: 'bilibili', name: 'B站', icon: '📺', bgColor: 'bg-[#00a1d6]/15', textColor: 'text-[#00a1d6]', borderColor: 'border-[#00a1d6]/30' },
|
{ id: 'bilibili', name: 'B站', icon: '📺', bgColor: 'bg-[#00a1d6]/15', textColor: 'text-[#00a1d6]', borderColor: 'border-[#00a1d6]/30' },
|
||||||
{ id: 'kuaishou', name: '快手', icon: '⚡', bgColor: 'bg-[#ff4906]/15', textColor: 'text-[#ff4906]', borderColor: 'border-[#ff4906]/30' },
|
{ id: 'kuaishou', name: '快手', icon: '⚡', bgColor: 'bg-[#ff4906]/15', textColor: 'text-[#ff4906]', borderColor: 'border-[#ff4906]/30' },
|
||||||
{ id: 'weibo', name: '微博', icon: '🔴', bgColor: 'bg-[#e6162d]/15', textColor: 'text-[#e6162d]', borderColor: 'border-[#e6162d]/30' },
|
|
||||||
{ id: 'wechat', name: '微信视频号', icon: '💬', bgColor: 'bg-[#07c160]/15', textColor: 'text-[#07c160]', borderColor: 'border-[#07c160]/30' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export type PlatformId = typeof platformOptions[number]['id']
|
export type PlatformId = typeof platformOptions[number]['id']
|
||||||
|
|||||||
87
frontend/types/ai-config.ts
Normal file
87
frontend/types/ai-config.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* AI 配置类型定义
|
||||||
|
* 与后端 schemas/ai_config.py 对齐
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AIProvider =
|
||||||
|
| 'oneapi'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'openai'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'qwen'
|
||||||
|
| 'doubao'
|
||||||
|
| 'zhipu'
|
||||||
|
| 'moonshot'
|
||||||
|
|
||||||
|
export interface AIModelsConfig {
|
||||||
|
text: string
|
||||||
|
vision: string
|
||||||
|
audio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIParametersConfig {
|
||||||
|
temperature: number
|
||||||
|
max_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 请求 =====
|
||||||
|
|
||||||
|
export interface AIConfigUpdate {
|
||||||
|
provider: AIProvider
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
models: AIModelsConfig
|
||||||
|
parameters: AIParametersConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetModelsRequest {
|
||||||
|
provider: AIProvider
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestConnectionRequest {
|
||||||
|
provider: AIProvider
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
models: AIModelsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 响应 =====
|
||||||
|
|
||||||
|
export interface AIConfigResponse {
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key_masked: string
|
||||||
|
models: AIModelsConfig
|
||||||
|
parameters: AIParametersConfig
|
||||||
|
available_models: Record<string, ModelInfo[]>
|
||||||
|
is_configured: boolean
|
||||||
|
last_test_at?: string | null
|
||||||
|
last_test_result?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsListResponse {
|
||||||
|
success: boolean
|
||||||
|
models: Record<string, ModelInfo[]>
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelTestResult {
|
||||||
|
success: boolean
|
||||||
|
latency_ms?: number | null
|
||||||
|
error?: string | null
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionTestResponse {
|
||||||
|
success: boolean
|
||||||
|
results: Record<string, ModelTestResult>
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@ -1,75 +1,125 @@
|
|||||||
/**
|
/**
|
||||||
* 视频审核相关类型定义
|
* 视频审核相关类型定义
|
||||||
|
* 与后端 schemas/review.py 对齐
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type TaskStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
// 审核任务状态(区别于 task.ts 中的 TaskStatus)
|
||||||
|
export type ReviewTaskStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'approved' | 'rejected'
|
||||||
|
|
||||||
export type RiskLevel = 'high' | 'medium' | 'low'
|
export type RiskLevel = 'high' | 'medium' | 'low'
|
||||||
|
|
||||||
export type ViolationType =
|
export type ViolationType =
|
||||||
| 'forbidden_word'
|
| 'forbidden_word'
|
||||||
|
| 'efficacy_claim'
|
||||||
| 'competitor_logo'
|
| 'competitor_logo'
|
||||||
| 'duration_short'
|
| 'duration_short'
|
||||||
| 'mention_missing'
|
| 'mention_missing'
|
||||||
|
| 'brand_safety'
|
||||||
|
|
||||||
export type ViolationSource = 'speech' | 'subtitle' | 'visual'
|
export type ViolationSource = 'text' | 'speech' | 'subtitle' | 'visual'
|
||||||
|
|
||||||
|
export type SoftRiskAction = 'confirm' | 'note'
|
||||||
|
|
||||||
|
export type Platform = 'douyin' | 'xiaohongshu' | 'bilibili' | 'kuaishou'
|
||||||
|
|
||||||
|
// 文本位置(脚本审核)
|
||||||
|
export interface Position {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 违规项(与后端 Violation 对齐)
|
||||||
export interface Violation {
|
export interface Violation {
|
||||||
id: string
|
|
||||||
type: ViolationType
|
type: ViolationType
|
||||||
content: string
|
content: string
|
||||||
timestamp: number
|
severity: RiskLevel
|
||||||
source: ViolationSource
|
|
||||||
riskLevel: RiskLevel
|
|
||||||
suggestion: string
|
suggestion: string
|
||||||
|
// 文本审核字段
|
||||||
|
position?: Position | null
|
||||||
|
// 视频审核字段
|
||||||
|
timestamp?: number | null
|
||||||
|
timestamp_end?: number | null
|
||||||
|
source?: ViolationSource | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SoftWarning {
|
// 软性风控提示(与后端 SoftRiskWarning 对齐)
|
||||||
id: string
|
export interface SoftRiskWarning {
|
||||||
type: string
|
code: string
|
||||||
content: string
|
message: string
|
||||||
suggestion: string
|
action_required: SoftRiskAction
|
||||||
|
blocking: boolean
|
||||||
|
context?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 前端内部使用的审核任务状态对象
|
||||||
export interface ReviewTask {
|
export interface ReviewTask {
|
||||||
reviewId: string
|
review_id: string
|
||||||
title?: string
|
title?: string
|
||||||
status: TaskStatus
|
status: ReviewTaskStatus
|
||||||
progress?: number
|
progress?: number
|
||||||
currentStep?: string
|
current_step?: string
|
||||||
score?: number
|
score?: number
|
||||||
summary?: string
|
summary?: string
|
||||||
violations?: Violation[]
|
violations?: Violation[]
|
||||||
softWarnings?: SoftWarning[]
|
soft_warnings?: SoftRiskWarning[]
|
||||||
createdAt: string
|
created_at: string
|
||||||
completedAt?: string
|
completed_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 请求/响应类型 ====================
|
||||||
|
|
||||||
export interface VideoReviewRequest {
|
export interface VideoReviewRequest {
|
||||||
videoUrl?: string
|
video_url: string
|
||||||
platform: string
|
platform: Platform
|
||||||
brandId?: string
|
brand_id: string
|
||||||
creatorId?: string
|
creator_id: string
|
||||||
title?: string
|
competitors?: string[]
|
||||||
|
requirements?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoReviewResponse {
|
export interface VideoReviewResponse {
|
||||||
reviewId: string
|
review_id: string
|
||||||
status: TaskStatus
|
status: ReviewTaskStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewProgressResponse {
|
export interface ReviewProgressResponse {
|
||||||
reviewId: string
|
review_id: string
|
||||||
status: TaskStatus
|
status: ReviewTaskStatus
|
||||||
progress: number
|
progress: number
|
||||||
currentStep: string
|
current_step: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewResultResponse {
|
export interface ReviewResultResponse {
|
||||||
reviewId: string
|
review_id: string
|
||||||
status: TaskStatus
|
status: ReviewTaskStatus
|
||||||
score: number
|
score: number
|
||||||
summary: string
|
summary: string
|
||||||
violations: Violation[]
|
violations: Violation[]
|
||||||
softWarnings: SoftWarning[]
|
soft_warnings: SoftRiskWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 脚本预审 ====================
|
||||||
|
|
||||||
|
export interface SoftRiskContext {
|
||||||
|
violation_rate?: number
|
||||||
|
violation_threshold?: number
|
||||||
|
asr_confidence?: number
|
||||||
|
ocr_confidence?: number
|
||||||
|
has_history_violation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptReviewRequest {
|
||||||
|
content: string
|
||||||
|
platform: Platform
|
||||||
|
brand_id: string
|
||||||
|
required_points?: string[]
|
||||||
|
soft_risk_context?: SoftRiskContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptReviewResponse {
|
||||||
|
score: number
|
||||||
|
summary: string
|
||||||
|
violations: Violation[]
|
||||||
|
missing_points?: string[]
|
||||||
|
soft_warnings: SoftRiskWarning[]
|
||||||
}
|
}
|
||||||
|
|||||||
98
frontend/types/rules.ts
Normal file
98
frontend/types/rules.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 规则管理类型定义
|
||||||
|
* 与后端 api/rules.py 对齐
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 违禁词 =====
|
||||||
|
|
||||||
|
export interface ForbiddenWordCreate {
|
||||||
|
word: string
|
||||||
|
category: string
|
||||||
|
severity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForbiddenWordResponse {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
category: string
|
||||||
|
severity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForbiddenWordListResponse {
|
||||||
|
items: ForbiddenWordResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 白名单 =====
|
||||||
|
|
||||||
|
export interface WhitelistCreate {
|
||||||
|
term: string
|
||||||
|
reason: string
|
||||||
|
brand_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhitelistResponse {
|
||||||
|
id: string
|
||||||
|
term: string
|
||||||
|
reason: string
|
||||||
|
brand_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhitelistListResponse {
|
||||||
|
items: WhitelistResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 竞品 =====
|
||||||
|
|
||||||
|
export interface CompetitorCreate {
|
||||||
|
name: string
|
||||||
|
brand_id: string
|
||||||
|
logo_url?: string
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitorResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
brand_id: string
|
||||||
|
logo_url?: string | null
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitorListResponse {
|
||||||
|
items: CompetitorResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 平台规则 =====
|
||||||
|
|
||||||
|
export interface PlatformRuleResponse {
|
||||||
|
platform: string
|
||||||
|
rules: Record<string, unknown>[]
|
||||||
|
version: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformListResponse {
|
||||||
|
items: PlatformRuleResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 规则冲突检测 =====
|
||||||
|
|
||||||
|
export interface RuleValidateRequest {
|
||||||
|
brand_id: string
|
||||||
|
platform: string
|
||||||
|
brief_rules: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConflict {
|
||||||
|
brief_rule: string
|
||||||
|
platform_rule: string
|
||||||
|
suggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleValidateResponse {
|
||||||
|
conflicts: RuleConflict[]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user