diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9bd5075..86c228e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -18,11 +18,9 @@ from app.models import ( Tenant, AIConfig, ReviewTask, - ManualTask, ForbiddenWord, WhitelistItem, Competitor, - RiskException, ) # Alembic Config 对象 diff --git a/backend/alembic/versions/001_initial_tables.py b/backend/alembic/versions/001_initial_tables.py index 45b119f..61441da 100644 --- a/backend/alembic/versions/001_initial_tables.py +++ b/backend/alembic/versions/001_initial_tables.py @@ -32,18 +32,6 @@ def upgrade() -> None: ) task_status_enum.create(op.get_bind(), checkfirst=True) - risk_target_type_enum = postgresql.ENUM( - 'influencer', 'order', 'content', - name='risk_target_type_enum' - ) - risk_target_type_enum.create(op.get_bind(), checkfirst=True) - - risk_exception_status_enum = postgresql.ENUM( - 'pending', 'approved', 'rejected', 'expired', 'revoked', - name='risk_exception_status_enum' - ) - risk_exception_status_enum.create(op.get_bind(), checkfirst=True) - # 租户表 op.create_table( 'tenants', @@ -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_status', 'review_tasks', ['status']) - # 人工任务表 - op.create_table( - 'manual_tasks', - sa.Column('id', sa.String(64), primary_key=True), - sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False), - sa.Column('review_task_id', sa.String(64), sa.ForeignKey('review_tasks.id', ondelete='SET NULL'), nullable=True), - sa.Column('video_url', sa.String(2048), nullable=False), - sa.Column('platform', platform_enum, nullable=False), - sa.Column('creator_id', sa.String(64), nullable=False), - sa.Column('status', task_status_enum, nullable=False, default='pending'), - sa.Column('approve_comment', sa.Text(), nullable=True), - sa.Column('reject_reason', sa.Text(), nullable=True), - sa.Column('reject_violations', postgresql.JSONB(), nullable=True), - sa.Column('reviewer_id', sa.String(64), nullable=True), - sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), - ) - op.create_index('ix_manual_tasks_tenant_id', 'manual_tasks', ['tenant_id']) - op.create_index('ix_manual_tasks_review_task_id', 'manual_tasks', ['review_task_id']) - op.create_index('ix_manual_tasks_creator_id', 'manual_tasks', ['creator_id']) - op.create_index('ix_manual_tasks_status', 'manual_tasks', ['status']) - # 违禁词表 op.create_table( 'forbidden_words', @@ -169,49 +134,17 @@ def upgrade() -> None: op.create_index('ix_competitors_tenant_id', 'competitors', ['tenant_id']) op.create_index('ix_competitors_brand_id', 'competitors', ['brand_id']) - # 特例审批表 - op.create_table( - 'risk_exceptions', - sa.Column('id', sa.String(64), primary_key=True), - sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False), - sa.Column('applicant_id', sa.String(64), nullable=False), - sa.Column('apply_time', sa.DateTime(timezone=True), nullable=False), - sa.Column('target_type', risk_target_type_enum, nullable=False), - sa.Column('target_id', sa.String(64), nullable=False), - sa.Column('risk_rule_id', sa.String(64), nullable=False), - sa.Column('status', risk_exception_status_enum, nullable=False, default='pending'), - sa.Column('valid_start_time', sa.DateTime(timezone=True), nullable=False), - sa.Column('valid_end_time', sa.DateTime(timezone=True), nullable=False), - sa.Column('reason_category', sa.String(100), nullable=False), - sa.Column('justification', sa.Text(), nullable=False), - sa.Column('attachment_url', sa.String(2048), nullable=True), - sa.Column('current_approver_id', sa.String(64), nullable=True), - sa.Column('approval_chain_log', postgresql.JSONB(), nullable=False, server_default='[]'), - sa.Column('auto_rejected', sa.Boolean(), nullable=False, default=False), - sa.Column('rejection_reason', sa.Text(), nullable=True), - sa.Column('last_status_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), - ) - op.create_index('ix_risk_exceptions_tenant_id', 'risk_exceptions', ['tenant_id']) - op.create_index('ix_risk_exceptions_applicant_id', 'risk_exceptions', ['applicant_id']) - op.create_index('ix_risk_exceptions_target_id', 'risk_exceptions', ['target_id']) - op.create_index('ix_risk_exceptions_status', 'risk_exceptions', ['status']) def downgrade() -> None: # 删除表 - op.drop_table('risk_exceptions') op.drop_table('competitors') op.drop_table('whitelist_items') op.drop_table('forbidden_words') - op.drop_table('manual_tasks') op.drop_table('review_tasks') op.drop_table('ai_configs') op.drop_table('tenants') # 删除枚举类型 - op.execute('DROP TYPE IF EXISTS risk_exception_status_enum') - op.execute('DROP TYPE IF EXISTS risk_target_type_enum') op.execute('DROP TYPE IF EXISTS task_status_enum') op.execute('DROP TYPE IF EXISTS platform_enum') diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py deleted file mode 100644 index 1d0e9cd..0000000 --- a/backend/app/api/metrics.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 6c0d357..6ec2c1c 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -279,18 +279,17 @@ async def search_agencies( ) agencies = list(result.scalars().all()) - return { - "items": [ - AgencySummary( - id=a.id, - name=a.name, - logo=a.logo, - contact_name=a.contact_name, - force_pass_enabled=a.force_pass_enabled, - ).model_dump() - for a in agencies - ] - } + items = [ + AgencySummary( + id=a.id, + name=a.name, + logo=a.logo, + contact_name=a.contact_name, + force_pass_enabled=a.force_pass_enabled, + ).model_dump() + for a in agencies + ] + return {"items": items, "total": len(items)} @router.get("/search/creators") @@ -307,16 +306,15 @@ async def search_creators( ) creators = list(result.scalars().all()) - return { - "items": [ - CreatorSummary( - id=c.id, - name=c.name, - avatar=c.avatar, - douyin_account=c.douyin_account, - xiaohongshu_account=c.xiaohongshu_account, - bilibili_account=c.bilibili_account, - ).model_dump() - for c in creators - ] - } + items = [ + CreatorSummary( + id=c.id, + name=c.name, + avatar=c.avatar, + douyin_account=c.douyin_account, + xiaohongshu_account=c.xiaohongshu_account, + bilibili_account=c.bilibili_account, + ).model_dump() + for c in creators + ] + return {"items": items, "total": len(items)} diff --git a/backend/app/api/risk_exceptions.py b/backend/app/api/risk_exceptions.py deleted file mode 100644 index 8ad27fe..0000000 --- a/backend/app/api/risk_exceptions.py +++ /dev/null @@ -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) diff --git a/backend/app/database.py b/backend/app/database.py index 1696732..ab7f007 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -21,14 +21,12 @@ from app.models import ( Brief, # AI 配置 AIConfig, - # 审核(旧模型) + # 审核 ReviewTask, - ManualTask, # 规则 ForbiddenWord, WhitelistItem, Competitor, - RiskException, # 兼容 Tenant, ) @@ -97,12 +95,10 @@ __all__ = [ "AIConfig", # 审核 "ReviewTask", - "ManualTask", # 规则 "ForbiddenWord", "WhitelistItem", "Competitor", - "RiskException", # 兼容 "Tenant", ] diff --git a/backend/app/main.py b/backend/app/main.py index 8663aa6..11c2081 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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( @@ -31,8 +31,6 @@ app.include_router(videos.router, prefix="/api/v1") app.include_router(tasks.router, prefix="/api/v1") app.include_router(rules.router, prefix="/api/v1") app.include_router(ai_config.router, prefix="/api/v1") -app.include_router(risk_exceptions.router, prefix="/api/v1") -app.include_router(metrics.router, prefix="/api/v1") app.include_router(sse.router, prefix="/api/v1") app.include_router(projects.router, prefix="/api/v1") app.include_router(briefs.router, prefix="/api/v1") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1e17452..f7adfef 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,10 +9,8 @@ from app.models.project import Project, project_agency_association from app.models.task import Task, TaskStage, TaskStatus from app.models.brief import Brief 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.risk_exception import RiskException - # 保留 Tenant 兼容旧代码,但新代码应使用 Brand from app.models.tenant import Tenant @@ -37,15 +35,13 @@ __all__ = [ "Brief", # AI 配置 "AIConfig", - # 审核(旧模型,保留兼容) + # 审核 "ReviewTask", - "ManualTask", "Platform", # 规则 "ForbiddenWord", "WhitelistItem", "Competitor", - "RiskException", # 兼容 "Tenant", ] diff --git a/backend/app/models/review.py b/backend/app/models/review.py index f4a371d..ff033b7 100644 --- a/backend/app/models/review.py +++ b/backend/app/models/review.py @@ -85,80 +85,5 @@ class ReviewTask(Base, TimestampMixin): # 关联 tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks") - manual_task: Mapped[Optional["ManualTask"]] = relationship( - "ManualTask", - back_populates="review_task", - uselist=False, - ) - def __repr__(self) -> str: return f"" - - -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"" diff --git a/backend/app/models/risk_exception.py b/backend/app/models/risk_exception.py deleted file mode 100644 index a0aeda7..0000000 --- a/backend/app/models/risk_exception.py +++ /dev/null @@ -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"" diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index 4e998cb..a6a911f 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -9,9 +9,8 @@ from app.models.base import Base, TimestampMixin if TYPE_CHECKING: from app.models.ai_config import AIConfig - from app.models.review import ReviewTask, ManualTask + from app.models.review import ReviewTask from app.models.rule import ForbiddenWord, WhitelistItem, Competitor - from app.models.risk_exception import RiskException class Tenant(Base, TimestampMixin): @@ -34,11 +33,6 @@ class Tenant(Base, TimestampMixin): back_populates="tenant", lazy="selectin", ) - manual_tasks: Mapped[list["ManualTask"]] = relationship( - "ManualTask", - back_populates="tenant", - lazy="selectin", - ) forbidden_words: Mapped[list["ForbiddenWord"]] = relationship( "ForbiddenWord", back_populates="tenant", @@ -54,11 +48,5 @@ class Tenant(Base, TimestampMixin): back_populates="tenant", lazy="selectin", ) - risk_exceptions: Mapped[list["RiskException"]] = relationship( - "RiskException", - back_populates="tenant", - lazy="selectin", - ) - def __repr__(self) -> str: return f"" diff --git a/backend/app/schemas/review.py b/backend/app/schemas/review.py index 5499c95..d6fe657 100644 --- a/backend/app/schemas/review.py +++ b/backend/app/schemas/review.py @@ -171,142 +171,3 @@ class VideoReviewResultResponse(BaseModel): violations: list[Violation] = Field(default_factory=list, description="违规项列表") soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示") - -# ==================== 一致性指标 ==================== - -class ConsistencyWindow(str, Enum): - """一致性指标计算周期""" - ROLLING_30D = "rolling_30d" - SNAPSHOT_WEEK = "snapshot_week" - SNAPSHOT_MONTH = "snapshot_month" - - -class RuleConsistencyMetric(BaseModel): - """按规则类型的指标""" - rule_type: ViolationType = Field(..., description="规则类型") - total_reviews: int = Field(..., ge=0, description="总审核数") - violation_count: int = Field(..., ge=0, description="违规数") - violation_rate: float = Field(..., ge=0, le=1, description="违规率(0-1)") - - -class ConsistencyMetricsResponse(BaseModel): - """一致性指标响应""" - influencer_id: str = Field(..., description="达人 ID") - window: ConsistencyWindow = Field(..., description="计算周期") - period_start: datetime = Field(..., description="周期起始时间") - period_end: datetime = Field(..., description="周期结束时间") - metrics: list[RuleConsistencyMetric] = Field(default_factory=list) - - -# ==================== 特例审批(风控豁免) ==================== - -class RiskTargetType(str, Enum): - """特例目标类型""" - INFLUENCER = "influencer" - ORDER = "order" - CONTENT = "content" - - -class RiskExceptionStatus(str, Enum): - """特例审批状态""" - PENDING = "pending" - APPROVED = "approved" - REJECTED = "rejected" - EXPIRED = "expired" - REVOKED = "revoked" - - -class RiskExceptionCreateRequest(BaseModel): - """创建特例请求""" - applicant_id: str = Field(..., description="申请人") - target_type: RiskTargetType = Field(..., description="目标类型") - target_id: str = Field(..., description="目标 ID") - risk_rule_id: str = Field(..., description="豁免规则 ID") - reason_category: str = Field(..., description="原因分类") - justification: str = Field(..., min_length=1, description="详细理由") - attachment_url: Optional[str] = Field(None, description="附件链接") - current_approver_id: str = Field(..., description="当前审批人") - valid_start_time: datetime = Field(..., description="生效开始时间") - valid_end_time: datetime = Field(..., description="生效结束时间") - - -class RiskExceptionRecord(BaseModel): - """特例记录""" - record_id: str = Field(..., description="记录 ID") - applicant_id: str = Field(..., description="申请人") - apply_time: datetime = Field(..., description="申请时间") - target_type: RiskTargetType = Field(..., description="目标类型") - target_id: str = Field(..., description="目标 ID") - risk_rule_id: str = Field(..., description="豁免规则 ID") - status: RiskExceptionStatus = Field(..., description="状态") - valid_start_time: datetime = Field(..., description="生效开始时间") - valid_end_time: datetime = Field(..., description="生效结束时间") - reason_category: str = Field(..., description="原因分类") - justification: str = Field(..., description="详细理由") - attachment_url: Optional[str] = Field(None, description="附件链接") - current_approver_id: Optional[str] = Field(None, description="当前审批人") - approval_chain_log: list[dict] = Field(default_factory=list, description="审批流转日志") - auto_rejected: bool = Field(default=False, description="是否超时自动拒绝") - rejection_reason: Optional[str] = Field(None, description="驳回原因") - last_status_at: Optional[datetime] = Field(None, description="最近状态变更时间") - - -class RiskExceptionDecisionRequest(BaseModel): - """特例审批决策请求""" - approver_id: str = Field(..., description="审批人") - comment: Optional[str] = Field(None, description="审批备注") - - -# ==================== 审核任务 ==================== - -class TaskCreateRequest(BaseModel): - """创建任务请求""" - platform: Platform = Field(..., description="投放平台") - creator_id: str = Field(..., description="达人 ID") - video_url: Optional[HttpUrl] = Field(None, description="视频 URL") - script_content: Optional[str] = Field(None, min_length=1, description="脚本内容") - script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL") - - -class TaskScriptUploadRequest(BaseModel): - """上传脚本请求""" - script_content: Optional[str] = Field(None, min_length=1, description="脚本内容") - script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL") - - -class TaskVideoUploadRequest(BaseModel): - """上传视频请求""" - video_url: HttpUrl = Field(..., description="视频 URL") - - -class TaskResponse(BaseModel): - """任务响应""" - task_id: str = Field(..., description="任务 ID") - video_url: Optional[str] = Field(None, description="视频 URL") - script_content: Optional[str] = Field(None, description="脚本内容") - script_file_url: Optional[str] = Field(None, description="脚本文档 URL") - has_script: bool = Field(..., description="是否已上传脚本") - has_video: bool = Field(..., description="是否已上传视频") - platform: Platform = Field(..., description="投放平台") - creator_id: str = Field(..., description="达人 ID") - status: TaskStatus = Field(..., description="任务状态") - created_at: str = Field(..., description="创建时间") - - -class TaskListResponse(BaseModel): - """任务列表响应""" - items: list[TaskResponse] = Field(default_factory=list) - total: int = Field(..., description="总数") - page: int = Field(..., description="当前页") - page_size: int = Field(..., description="每页数量") - - -class TaskApproveRequest(BaseModel): - """通过任务请求""" - comment: Optional[str] = Field(None, description="备注") - - -class TaskRejectRequest(BaseModel): - """驳回任务请求""" - reason: str = Field(..., min_length=1, description="驳回原因") - violations: list[str] = Field(default_factory=list, description="违规类型列表") diff --git a/backend/tests/test_metrics_api.py b/backend/tests/test_metrics_api.py deleted file mode 100644 index aecdaa1..0000000 --- a/backend/tests/test_metrics_api.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py deleted file mode 100644 index d7bdc01..0000000 --- a/backend/tests/test_tasks_api.py +++ /dev/null @@ -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 diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index abe0f23..981a02f 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -162,6 +162,14 @@ function LoginForm() { + {/* 注册链接 */} +

+ 还没有账号?{' '} + + 立即注册 + +

+ {/* Demo 登录 */}

快速体验(Demo 账号)

diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..23a3127 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -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('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 ( +
+
+ {/* 返回 */} + + + 返回登录 + + + {/* Logo */} +
+
+ +
+
+ 注册账号 +

加入秒思智能审核平台

+
+
+ + {/* 注册表单 */} +
+ {error && ( +
+ + {error} +
+ )} + + {/* 角色选择 */} +
+ +
+ {roleOptions.map((opt) => ( + + ))} +
+

+ {roleOptions.find((o) => o.value === role)?.desc} +

+
+ + {/* 用户名 */} +
+ +
+ + 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 + /> +
+
+ + {/* 邮箱 */} +
+ +
+ + 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" + /> +
+
+ + {/* 手机号 */} +
+ +
+ + 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" + /> +
+
+ + {/* 密码 */} +
+ +
+ + 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 + /> +
+
+ + {/* 确认密码 */} +
+ +
+ + 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 + /> +
+
+ + +
+ + {/* 底部链接 */} +

+ 已有账号?{' '} + + 立即登录 + +

+
+
+ ) +} diff --git a/frontend/hooks/useReview.ts b/frontend/hooks/useReview.ts index 630cf9c..4aa53c6 100644 --- a/frontend/hooks/useReview.ts +++ b/frontend/hooks/useReview.ts @@ -5,7 +5,7 @@ import { api } from '@/lib/api' import type { VideoReviewRequest, ReviewTask, - TaskStatus, + ReviewTaskStatus, } from '@/types/review' interface UseReviewOptions { @@ -36,11 +36,11 @@ export function useReview(options: UseReviewOptions = {}) { try { const response = await api.submitVideoReview(data) setTask({ - reviewId: response.reviewId, + review_id: response.review_id, status: response.status, - createdAt: new Date().toISOString(), + created_at: new Date().toISOString(), }) - return response.reviewId + return response.review_id } catch (err) { const error = err instanceof Error ? err : new Error('提交失败') setError(error) @@ -59,11 +59,11 @@ export function useReview(options: UseReviewOptions = {}) { const progress = await api.getReviewProgress(reviewId) setTask((prev) => ({ ...prev, - reviewId: progress.reviewId, + review_id: progress.review_id, status: progress.status, progress: progress.progress, - currentStep: progress.currentStep, - createdAt: prev?.createdAt || new Date().toISOString(), + current_step: progress.current_step, + created_at: prev?.created_at || new Date().toISOString(), })) return progress } catch (err) { @@ -80,14 +80,14 @@ export function useReview(options: UseReviewOptions = {}) { try { const result = await api.getReviewResult(reviewId) const updatedTask: ReviewTask = { - reviewId: result.reviewId, + review_id: result.review_id, status: result.status, score: result.score, summary: result.summary, violations: result.violations, - softWarnings: result.softWarnings, - createdAt: task?.createdAt || new Date().toISOString(), - completedAt: new Date().toISOString(), + soft_warnings: result.soft_warnings, + created_at: task?.created_at || new Date().toISOString(), + completed_at: new Date().toISOString(), } setTask(updatedTask) return updatedTask @@ -96,7 +96,7 @@ export function useReview(options: UseReviewOptions = {}) { setError(error) throw error } - }, [task?.createdAt]) + }, [task?.created_at]) /** * 清除轮询定时器 @@ -203,13 +203,13 @@ export function useReviewResult(reviewId: string | null) { try { const result = await api.getReviewResult(reviewId) setTask({ - reviewId: result.reviewId, + review_id: result.review_id, status: result.status, score: result.score, summary: result.summary, violations: result.violations, - softWarnings: result.softWarnings, - createdAt: new Date().toISOString(), + soft_warnings: result.soft_warnings, + created_at: new Date().toISOString(), }) } catch (err) { setError(err instanceof Error ? err : new Error('查询失败')) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index c492523..313044b 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -8,6 +8,8 @@ import type { VideoReviewResponse, ReviewProgressResponse, ReviewResultResponse, + ScriptReviewRequest, + ScriptReviewResponse, } from '@/types/review' import type { TaskResponse, @@ -40,6 +42,29 @@ import type { AgencyDashboard, BrandDashboard, } 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 STORAGE_KEY_ACCESS = 'miaosi_access_token' @@ -67,7 +92,8 @@ export interface User { export interface LoginRequest { email?: string phone?: string - password: string + password?: string + sms_code?: string } export interface RegisterRequest { @@ -102,6 +128,14 @@ export interface UploadPolicyResponse { max_size_mb: number } +export interface FileUploadedResponse { + url: string + file_key: string + file_name: string + file_size: number + file_type: string +} + // ==================== Token 管理 ==================== function getAccessToken(): string | null { @@ -283,8 +317,8 @@ class ApiClient { /** * 文件上传完成回调 */ - async fileUploaded(fileKey: string, fileName: string, fileSize: number, fileType: string): Promise<{ url: string }> { - const response = await this.client.post<{ url: string }>('/upload/complete', { + async fileUploaded(fileKey: string, fileName: string, fileSize: number, fileType: string): Promise { + const response = await this.client.post('/upload/complete', { file_key: fileKey, file_name: fileName, file_size: fileSize, @@ -299,12 +333,7 @@ class ApiClient { * 提交视频审核 */ async submitVideoReview(data: VideoReviewRequest): Promise { - const response = await this.client.post('/videos/review', { - video_url: data.videoUrl, - platform: data.platform, - brand_id: data.brandId, - creator_id: data.creatorId, - }) + const response = await this.client.post('/videos/review', data) return response.data } @@ -603,6 +632,144 @@ class ApiClient { return response.data } + // ==================== 脚本预审 ==================== + + /** + * 脚本预审(AI 审核) + */ + async reviewScriptContent(data: ScriptReviewRequest): Promise { + const response = await this.client.post('/scripts/review', data) + return response.data + } + + // ==================== 规则管理 ==================== + + /** + * 查询违禁词列表 + */ + async listForbiddenWords(category?: string): Promise { + const response = await this.client.get('/rules/forbidden-words', { + params: category ? { category } : undefined, + }) + return response.data + } + + /** + * 添加违禁词 + */ + async addForbiddenWord(data: ForbiddenWordCreate): Promise { + const response = await this.client.post('/rules/forbidden-words', data) + return response.data + } + + /** + * 删除违禁词 + */ + async deleteForbiddenWord(wordId: string): Promise { + await this.client.delete(`/rules/forbidden-words/${wordId}`) + } + + /** + * 查询白名单 + */ + async listWhitelist(brandId?: string): Promise { + const response = await this.client.get('/rules/whitelist', { + params: brandId ? { brand_id: brandId } : undefined, + }) + return response.data + } + + /** + * 添加白名单 + */ + async addToWhitelist(data: WhitelistCreate): Promise { + const response = await this.client.post('/rules/whitelist', data) + return response.data + } + + /** + * 查询竞品列表 + */ + async listCompetitors(brandId?: string): Promise { + const response = await this.client.get('/rules/competitors', { + params: brandId ? { brand_id: brandId } : undefined, + }) + return response.data + } + + /** + * 添加竞品 + */ + async addCompetitor(data: CompetitorCreate): Promise { + const response = await this.client.post('/rules/competitors', data) + return response.data + } + + /** + * 删除竞品 + */ + async deleteCompetitor(competitorId: string): Promise { + await this.client.delete(`/rules/competitors/${competitorId}`) + } + + /** + * 查询所有平台规则 + */ + async listPlatformRules(): Promise { + const response = await this.client.get('/rules/platforms') + return response.data + } + + /** + * 查询指定平台规则 + */ + async getPlatformRules(platform: string): Promise { + const response = await this.client.get(`/rules/platforms/${platform}`) + return response.data + } + + /** + * 规则冲突检测 + */ + async validateRules(data: RuleValidateRequest): Promise { + const response = await this.client.post('/rules/validate', data) + return response.data + } + + // ==================== AI 配置 ==================== + + /** + * 获取 AI 配置 + */ + async getAIConfig(): Promise { + const response = await this.client.get('/ai-config') + return response.data + } + + /** + * 更新 AI 配置 + */ + async updateAIConfig(data: AIConfigUpdate): Promise { + const response = await this.client.put('/ai-config', data) + return response.data + } + + /** + * 获取可用模型列表 + */ + async getAIModels(data: GetModelsRequest): Promise { + const response = await this.client.post('/ai-config/models', data) + return response.data + } + + /** + * 测试 AI 连接 + */ + async testAIConnection(data: TestConnectionRequest): Promise { + const response = await this.client.post('/ai-config/test', data) + return response.data + } + // ==================== 健康检查 ==================== /** diff --git a/frontend/lib/platforms.ts b/frontend/lib/platforms.ts index 1415e70..51c139c 100644 --- a/frontend/lib/platforms.ts +++ b/frontend/lib/platforms.ts @@ -4,8 +4,6 @@ export const platformOptions = [ { 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: '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'] diff --git a/frontend/types/ai-config.ts b/frontend/types/ai-config.ts new file mode 100644 index 0000000..e75bff9 --- /dev/null +++ b/frontend/types/ai-config.ts @@ -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 + is_configured: boolean + last_test_at?: string | null + last_test_result?: Record | null +} + +export interface ModelInfo { + id: string + name: string +} + +export interface ModelsListResponse { + success: boolean + models: Record + error?: string | null +} + +export interface ModelTestResult { + success: boolean + latency_ms?: number | null + error?: string | null + model: string +} + +export interface ConnectionTestResponse { + success: boolean + results: Record + message: string +} diff --git a/frontend/types/review.ts b/frontend/types/review.ts index 7c92252..5b2a241 100644 --- a/frontend/types/review.ts +++ b/frontend/types/review.ts @@ -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 ViolationType = | 'forbidden_word' + | 'efficacy_claim' | 'competitor_logo' | 'duration_short' | '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 { - id: string type: ViolationType content: string - timestamp: number - source: ViolationSource - riskLevel: RiskLevel + severity: RiskLevel suggestion: string + // 文本审核字段 + position?: Position | null + // 视频审核字段 + timestamp?: number | null + timestamp_end?: number | null + source?: ViolationSource | null } -export interface SoftWarning { - id: string - type: string - content: string - suggestion: string +// 软性风控提示(与后端 SoftRiskWarning 对齐) +export interface SoftRiskWarning { + code: string + message: string + action_required: SoftRiskAction + blocking: boolean + context?: Record | null } +// 前端内部使用的审核任务状态对象 export interface ReviewTask { - reviewId: string + review_id: string title?: string - status: TaskStatus + status: ReviewTaskStatus progress?: number - currentStep?: string + current_step?: string score?: number summary?: string violations?: Violation[] - softWarnings?: SoftWarning[] - createdAt: string - completedAt?: string + soft_warnings?: SoftRiskWarning[] + created_at: string + completed_at?: string } +// ==================== 请求/响应类型 ==================== + export interface VideoReviewRequest { - videoUrl?: string - platform: string - brandId?: string - creatorId?: string - title?: string + video_url: string + platform: Platform + brand_id: string + creator_id: string + competitors?: string[] + requirements?: Record } export interface VideoReviewResponse { - reviewId: string - status: TaskStatus + review_id: string + status: ReviewTaskStatus } export interface ReviewProgressResponse { - reviewId: string - status: TaskStatus + review_id: string + status: ReviewTaskStatus progress: number - currentStep: string + current_step: string } export interface ReviewResultResponse { - reviewId: string - status: TaskStatus + review_id: string + status: ReviewTaskStatus score: number summary: string 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[] } diff --git a/frontend/types/rules.ts b/frontend/types/rules.ts new file mode 100644 index 0000000..af83610 --- /dev/null +++ b/frontend/types/rules.ts @@ -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[] + version: string + updated_at: string +} + +export interface PlatformListResponse { + items: PlatformRuleResponse[] + total: number +} + +// ===== 规则冲突检测 ===== + +export interface RuleValidateRequest { + brand_id: string + platform: string + brief_rules: Record +} + +export interface RuleConflict { + brief_rule: string + platform_rule: string + suggestion: string +} + +export interface RuleValidateResponse { + conflicts: RuleConflict[] +}