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
co-authored by Claude Opus 4.6
parent a32102f583
commit 4a3c7e7923
22 changed files with 725 additions and 1298 deletions
-2
View File
@@ -18,11 +18,9 @@ from app.models import (
Tenant,
AIConfig,
ReviewTask,
ManualTask,
ForbiddenWord,
WhitelistItem,
Competitor,
RiskException,
)
# Alembic Config 对象
@@ -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')
-87
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,
)
+23 -25
View File
@@ -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)}
-226
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)
+1 -5
View File
@@ -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",
]
+1 -3
View File
@@ -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")
+2 -6
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.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",
]
-75
View File
@@ -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"<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})>"
-104
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})>"
+1 -13
View File
@@ -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"<Tenant(id={self.id}, name={self.name})>"
-139
View File
@@ -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="违规类型列表")
-62
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
-428
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