feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -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",
|
||||
]
|
||||
@@ -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})>"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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})>"
|
||||
@@ -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})>"
|
||||
@@ -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})>"
|
||||
@@ -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})>"
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user