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,92 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
支持异步数据库迁移
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# 导入配置和模型
|
||||
from app.config import settings
|
||||
from app.models.base import Base
|
||||
from app.models import (
|
||||
Tenant,
|
||||
AIConfig,
|
||||
ReviewTask,
|
||||
ManualTask,
|
||||
ForbiddenWord,
|
||||
WhitelistItem,
|
||||
Competitor,
|
||||
RiskException,
|
||||
)
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 设置数据库 URL
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# 日志配置
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# MetaData 对象用于 autogenerate
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
不需要数据库连接,只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""执行迁移"""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""异步运行迁移"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
使用异步引擎连接数据库
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,217 @@
|
||||
"""初始表结构
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2024-01-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 创建枚举类型
|
||||
platform_enum = postgresql.ENUM(
|
||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||
name='platform_enum'
|
||||
)
|
||||
platform_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
task_status_enum = postgresql.ENUM(
|
||||
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
||||
name='task_status_enum'
|
||||
)
|
||||
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',
|
||||
sa.Column('id', sa.String(64), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=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),
|
||||
)
|
||||
|
||||
# AI 配置表
|
||||
op.create_table(
|
||||
'ai_configs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), unique=True, nullable=False),
|
||||
sa.Column('provider', sa.String(50), nullable=False),
|
||||
sa.Column('base_url', sa.String(500), nullable=False),
|
||||
sa.Column('api_key_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('models', postgresql.JSONB(), nullable=False),
|
||||
sa.Column('temperature', sa.Float(), nullable=False, default=0.7),
|
||||
sa.Column('max_tokens', sa.Integer(), nullable=False, default=2000),
|
||||
sa.Column('available_models', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('last_test_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_test_result', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('is_configured', sa.Boolean(), nullable=False, default=False),
|
||||
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_ai_configs_tenant_id', 'ai_configs', ['tenant_id'])
|
||||
|
||||
# 审核任务表
|
||||
op.create_table(
|
||||
'review_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('video_url', sa.String(2048), nullable=False),
|
||||
sa.Column('platform', platform_enum, nullable=False),
|
||||
sa.Column('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('creator_id', sa.String(64), nullable=False),
|
||||
sa.Column('status', task_status_enum, nullable=False, default='pending'),
|
||||
sa.Column('progress', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('current_step', sa.String(100), nullable=False, default='等待处理'),
|
||||
sa.Column('score', sa.Integer(), nullable=True),
|
||||
sa.Column('summary', sa.Text(), nullable=True),
|
||||
sa.Column('violations', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('soft_warnings', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('requirements', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('competitors', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), 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_review_tasks_tenant_id', 'review_tasks', ['tenant_id'])
|
||||
op.create_index('ix_review_tasks_brand_id', 'review_tasks', ['brand_id'])
|
||||
op.create_index('ix_review_tasks_creator_id', 'review_tasks', ['creator_id'])
|
||||
op.create_index('ix_review_tasks_status', 'review_tasks', ['status'])
|
||||
|
||||
# 人工任务表
|
||||
op.create_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',
|
||||
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('word', sa.String(255), nullable=False),
|
||||
sa.Column('category', sa.String(100), nullable=False),
|
||||
sa.Column('severity', sa.String(50), nullable=False),
|
||||
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_forbidden_words_tenant_id', 'forbidden_words', ['tenant_id'])
|
||||
op.create_index('ix_forbidden_words_word', 'forbidden_words', ['word'])
|
||||
op.create_index('ix_forbidden_words_category', 'forbidden_words', ['category'])
|
||||
|
||||
# 白名单表
|
||||
op.create_table(
|
||||
'whitelist_items',
|
||||
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('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('term', sa.String(255), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=False),
|
||||
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_whitelist_items_tenant_id', 'whitelist_items', ['tenant_id'])
|
||||
op.create_index('ix_whitelist_items_brand_id', 'whitelist_items', ['brand_id'])
|
||||
op.create_index('ix_whitelist_items_term', 'whitelist_items', ['term'])
|
||||
|
||||
# 竞品表
|
||||
op.create_table(
|
||||
'competitors',
|
||||
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('brand_id', sa.String(64), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('logo_url', sa.String(2048), nullable=True),
|
||||
sa.Column('keywords', postgresql.JSONB(), 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_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')
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Add manual task script/video upload fields
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-02-04
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=True,
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_content", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_file_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"manual_tasks",
|
||||
sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("manual_tasks", "script_uploaded_at")
|
||||
op.drop_column("manual_tasks", "script_file_url")
|
||||
op.drop_column("manual_tasks", "script_content")
|
||||
op.alter_column(
|
||||
"manual_tasks",
|
||||
"video_url",
|
||||
existing_type=sa.String(length=2048),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("manual_tasks", "video_uploaded_at")
|
||||
Reference in New Issue
Block a user