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:
Your Name 2026-02-09 14:51:17 +08:00
parent a32102f583
commit 4a3c7e7923
22 changed files with 725 additions and 1298 deletions

View File

@ -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 对象

View File

@ -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')

View File

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

View File

@ -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)}

View File

@ -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)

View File

@ -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",
] ]

View File

@ -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")

View File

@ -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",
] ]

View File

@ -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})>"

View File

@ -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})>"

View File

@ -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})>"

View File

@ -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="违规类型列表")

View File

@ -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

View File

@ -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

View File

@ -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>

View 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>
)
}

View File

@ -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('查询失败'))

View File

@ -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
}
// ==================== 健康检查 ==================== // ==================== 健康检查 ====================
/** /**

View File

@ -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']

View 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
}

View File

@ -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
View 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[]
}