feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+23
View File
@@ -0,0 +1,23 @@
"""
数据库模型
导出所有 ORM 模型
"""
from app.models.base import Base, TimestampMixin
from app.models.tenant import Tenant
from app.models.ai_config import AIConfig
from app.models.review import ReviewTask, ManualTask
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
from app.models.risk_exception import RiskException
__all__ = [
"Base",
"TimestampMixin",
"Tenant",
"AIConfig",
"ReviewTask",
"ManualTask",
"ForbiddenWord",
"WhitelistItem",
"Competitor",
"RiskException",
]
+59
View File
@@ -0,0 +1,59 @@
"""
AI 配置模型
"""
from typing import TYPE_CHECKING, Optional
from datetime import datetime
from sqlalchemy import String, Text, Float, Integer, ForeignKey, DateTime
from app.models.types import JSONType
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from app.models.tenant import Tenant
class AIConfig(Base, TimestampMixin):
"""AI 服务配置表"""
__tablename__ = "ai_configs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
# 提供商配置
provider: Mapped[str] = mapped_column(String(50), nullable=False)
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
# 模型配置 (JSON)
# {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"}
models: Mapped[dict] = mapped_column(JSONType, nullable=False)
# 参数配置
temperature: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
max_tokens: Mapped[int] = mapped_column(Integer, default=2000, nullable=False)
# 可用模型缓存 (JSON)
available_models: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
# 测试结果
last_test_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
last_test_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
# 配置状态
is_configured: Mapped[bool] = mapped_column(default=False, nullable=False)
# 关联
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="ai_config")
def __repr__(self) -> str:
return f"<AIConfig(tenant_id={self.tenant_id}, provider={self.provider})>"
+29
View File
@@ -0,0 +1,29 @@
"""
数据库模型基类
提供公共字段和功能
"""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""声明基类"""
pass
class TimestampMixin:
"""时间戳 Mixin,提供 created_at 和 updated_at 字段"""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+164
View File
@@ -0,0 +1,164 @@
"""
审核任务模型
"""
from typing import TYPE_CHECKING, Optional
from datetime import datetime
from sqlalchemy import String, Integer, Float, Text, 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 TaskStatus(str, enum.Enum):
"""任务状态"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
APPROVED = "approved"
REJECTED = "rejected"
class Platform(str, enum.Enum):
"""投放平台"""
DOUYIN = "douyin"
XIAOHONGSHU = "xiaohongshu"
BILIBILI = "bilibili"
KUAISHOU = "kuaishou"
class ReviewTask(Base, TimestampMixin):
"""审核任务表 (AI 自动审核)"""
__tablename__ = "review_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,
)
# 视频信息
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
platform: Mapped[Platform] = mapped_column(
SQLEnum(Platform, name="platform_enum"),
nullable=False,
)
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# 审核状态
status: Mapped[TaskStatus] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum"),
default=TaskStatus.PENDING,
nullable=False,
index=True,
)
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
current_step: Mapped[str] = mapped_column(String(100), default="等待处理", nullable=False)
# 审核结果
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 违规详情 (JSON 数组)
# [{"type": "forbidden_word", "content": "最好", "severity": "high", ...}]
violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 软性风控提示 (JSON 数组)
soft_warnings: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 审核要求 (JSON)
requirements: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
# 竞品列表
competitors: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 错误信息
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 关联
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
@@ -0,0 +1,104 @@
"""
特例审批模型
"""
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})>"
+85
View File
@@ -0,0 +1,85 @@
"""
规则模型
违禁词、白名单、竞品
"""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String, Text, ForeignKey
from app.models.types import JSONType
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from app.models.tenant import Tenant
class ForbiddenWord(Base, TimestampMixin):
"""违禁词表"""
__tablename__ = "forbidden_words"
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,
)
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
category: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
severity: Mapped[str] = mapped_column(String(50), nullable=False)
# 关联
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="forbidden_words")
def __repr__(self) -> str:
return f"<ForbiddenWord(word={self.word}, category={self.category})>"
class WhitelistItem(Base, TimestampMixin):
"""白名单表"""
__tablename__ = "whitelist_items"
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,
)
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
term: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
# 关联
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="whitelist_items")
def __repr__(self) -> str:
return f"<WhitelistItem(term={self.term}, brand_id={self.brand_id})>"
class Competitor(Base, TimestampMixin):
"""竞品表"""
__tablename__ = "competitors"
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,
)
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
logo_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
# 关键词列表 (JSON 数组)
keywords: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 关联
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="competitors")
def __repr__(self) -> str:
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
+64
View File
@@ -0,0 +1,64 @@
"""
租户模型
"""
from typing import TYPE_CHECKING
from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
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.rule import ForbiddenWord, WhitelistItem, Competitor
from app.models.risk_exception import RiskException
class Tenant(Base, TimestampMixin):
"""租户表"""
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# 关联关系
ai_config: Mapped["AIConfig"] = relationship(
"AIConfig",
back_populates="tenant",
uselist=False,
lazy="selectin",
)
review_tasks: Mapped[list["ReviewTask"]] = relationship(
"ReviewTask",
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",
lazy="selectin",
)
whitelist_items: Mapped[list["WhitelistItem"]] = relationship(
"WhitelistItem",
back_populates="tenant",
lazy="selectin",
)
competitors: Mapped[list["Competitor"]] = relationship(
"Competitor",
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})>"
+6
View File
@@ -0,0 +1,6 @@
"""Shared SQLAlchemy column types with cross-database compatibility."""
from sqlalchemy import JSON
from sqlalchemy.dialects.postgresql import JSONB
# Use JSONB on PostgreSQL, fall back to JSON on other databases (e.g., SQLite for tests)
JSONType = JSON().with_variant(JSONB, "postgresql")