baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
16
backend/alembic/README.md
Normal file
16
backend/alembic/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# alembic/
|
||||
|
||||
Alembic 数据库迁移脚本占位:
|
||||
|
||||
## 执行顺序
|
||||
001 → 002 → 003 → 004(有外键依赖,必须按顺序)
|
||||
|
||||
## 版本规划
|
||||
- 001_from_banana.py # users/login_records/user_preferences(从banana搬3张,改造)
|
||||
- 002_multi_tenant_base.py # workspaces/workspace_members/user_api_keys
|
||||
- 003_business_core.py # 业务主体7张(products等)
|
||||
- 004_flywheel.py # preference_events
|
||||
|
||||
## 铁律
|
||||
- matrix_accounts / preference_profile 一期不建(二期预留)
|
||||
- Alembic 版本号统一管理,不允许手动改表
|
||||
62
backend/alembic/env.py
Normal file
62
backend/alembic/env.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
alembic/env.py — Alembic 运行环境配置
|
||||
从环境变量读取 DATABASE_URL,不硬编码。
|
||||
"""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# ── 确保 app 包可导入 ────────────────────────────────
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401 — 触发所有模型注册
|
||||
|
||||
config = context.config
|
||||
|
||||
# 从环境变量覆盖 DATABASE_URL
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
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 run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
29
backend/alembic/script.py.mako
Normal file
29
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
alembic/script.py.mako — 迁移文件模板
|
||||
"""
|
||||
"""${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"}
|
||||
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""001_banana_users_tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-09
|
||||
Alembic 001: 从 banana 搬 3 张表(users/login_records/user_preferences)
|
||||
删除 credits 字段,其余无改。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
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:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(64), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("username"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"login_records",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("ip_address", sa.String(64), nullable=True),
|
||||
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_login_records_user_id", "login_records", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("preferences_json", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_index("idx_login_records_user_id", "login_records")
|
||||
op.drop_table("login_records")
|
||||
op.drop_table("users")
|
||||
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""002_multitenant_workspace
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-09
|
||||
Alembic 002: 多租户基础(workspaces/workspace_members/user_api_keys)
|
||||
matrix_accounts 二期预留,一期不建。
|
||||
user_api_keys 不存 url 字段(token站固定自家站,基石B)。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
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.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("slug", sa.String(64), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workspace_members",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", sa.Enum("admin", "supervisor", "operator", name="userrole"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||
)
|
||||
op.create_index("idx_workspace_members_workspace_id", "workspace_members", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_api_keys",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column("encrypted_key", sa.String(512), nullable=False),
|
||||
sa.Column("key_last4", sa.String(4), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "workspace_id", "provider", name="uq_user_workspace_provider"),
|
||||
)
|
||||
op.create_index("idx_user_api_keys_workspace_id", "user_api_keys", ["workspace_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_user_api_keys_workspace_id", "user_api_keys")
|
||||
op.drop_table("user_api_keys")
|
||||
op.drop_index("idx_workspace_members_workspace_id", "workspace_members")
|
||||
op.drop_table("workspace_members")
|
||||
op.drop_table("workspaces")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
205
backend/alembic/versions/003_business_tables.py
Normal file
205
backend/alembic/versions/003_business_tables.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""003_business_tables
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-06-09
|
||||
Alembic 003: 业务主体7张表 + ai_call_logs
|
||||
products / benchmark_notes / banned_words /
|
||||
generation_tasks / text_candidates / image_candidates /
|
||||
delivery_packages / ai_call_logs
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_TASK_STATUS = sa.Enum(
|
||||
"pending", "generating", "pending_selection",
|
||||
"pending_review", "approved", "rejected", "archived",
|
||||
name="taskstatus",
|
||||
)
|
||||
_REVIEW_STATUS = sa.Enum("pending", "approved", "rejected", name="reviewstatus")
|
||||
_CANDIDATE_SOURCE = sa.Enum("ai", "import", name="candidatesource")
|
||||
_BANNED_LEVEL = sa.Enum("auto_fix", "soft_warn", "hard_block", name="bannedwordlevel")
|
||||
_BANNED_STATUS = sa.Enum("pass", "auto_fixed", "soft_warn", "hard_block", name="bannedwordstatus")
|
||||
_IMAGE_ROLE = sa.Enum("hook", "pain", "proof", "quality", "credit", "convert", "main", name="imagerole")
|
||||
_PKG_STATUS = sa.Enum("pending", "ready", "downloaded", name="packagestatus")
|
||||
_PRODUCT_SOURCE = sa.Enum("preset", "custom", name="productsource")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"products",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("category", sa.String(64), nullable=True),
|
||||
sa.Column("source", _PRODUCT_SOURCE, nullable=False, server_default="custom"),
|
||||
sa.Column("selling_points", sa.Text(), nullable=True),
|
||||
sa.Column("style_tone", sa.String(128), nullable=True),
|
||||
sa.Column("text_angles", sa.Text(), nullable=True),
|
||||
sa.Column("custom_prompt", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_products_workspace_id", "products", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"benchmark_notes",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("screenshot_url", sa.String(512), nullable=True),
|
||||
sa.Column("highlights", sa.Text(), nullable=True),
|
||||
sa.Column("link_url", sa.String(512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_benchmark_notes_product_id", "benchmark_notes", ["product_id"])
|
||||
|
||||
op.create_table(
|
||||
"banned_words",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("word", sa.String(64), nullable=False),
|
||||
sa.Column("level", _BANNED_LEVEL, nullable=False),
|
||||
sa.Column("replacement", sa.String(128), nullable=True),
|
||||
sa.Column("updatable", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_banned_words_workspace_id", "banned_words", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"generation_tasks",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("operator_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("theme", sa.String(256), nullable=True),
|
||||
sa.Column("text_count", sa.Integer(), nullable=False, server_default="5"),
|
||||
sa.Column("image_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("track", sa.String(16), nullable=False, server_default="ai"),
|
||||
sa.Column("status", _TASK_STATUS, nullable=False, server_default="pending"),
|
||||
sa.Column("mongo_trace_id", sa.String(24), nullable=True),
|
||||
sa.Column("review_status", _REVIEW_STATUS, nullable=True),
|
||||
sa.Column("reviewer_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("reject_reason", sa.Text(), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||
sa.ForeignKeyConstraint(["operator_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_generation_tasks_workspace_status", "generation_tasks", ["workspace_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"text_candidates",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("source", _CANDIDATE_SOURCE, nullable=False, server_default="ai"),
|
||||
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("score_json", sa.Text(), nullable=True),
|
||||
sa.Column("banned_word_status", _BANNED_STATUS, nullable=False, server_default="pass"),
|
||||
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_text_candidates_task_id", "text_candidates", ["task_id"])
|
||||
|
||||
op.create_table(
|
||||
"image_candidates",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", _IMAGE_ROLE, nullable=False, server_default="main"),
|
||||
sa.Column("url", sa.String(512), nullable=True),
|
||||
sa.Column("strategy", sa.String(4), nullable=True),
|
||||
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_image_candidates_task_id", "image_candidates", ["task_id"])
|
||||
|
||||
op.create_table(
|
||||
"delivery_packages",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", _PKG_STATUS, nullable=False, server_default="pending"),
|
||||
sa.Column("package_path", sa.String(512), nullable=True),
|
||||
sa.Column("download_url", sa.String(512), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ai_call_logs",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("key_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("provider", sa.String(32), nullable=True),
|
||||
sa.Column("model", sa.String(64), nullable=True),
|
||||
sa.Column("call_type", sa.String(32), nullable=True),
|
||||
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("completion_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("success", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("error_code", sa.String(32), nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["key_id"], ["user_api_keys.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_ai_call_logs_workspace_user", "ai_call_logs", ["workspace_id", "user_id"])
|
||||
op.create_index("idx_ai_call_logs_task_id", "ai_call_logs", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_ai_call_logs_task_id", "ai_call_logs")
|
||||
op.drop_index("idx_ai_call_logs_workspace_user", "ai_call_logs")
|
||||
op.drop_table("ai_call_logs")
|
||||
op.drop_table("delivery_packages")
|
||||
op.drop_index("idx_image_candidates_task_id", "image_candidates")
|
||||
op.drop_table("image_candidates")
|
||||
op.drop_index("idx_text_candidates_task_id", "text_candidates")
|
||||
op.drop_table("text_candidates")
|
||||
op.drop_index("idx_generation_tasks_workspace_status", "generation_tasks")
|
||||
op.drop_table("generation_tasks")
|
||||
op.drop_index("idx_banned_words_workspace_id", "banned_words")
|
||||
op.drop_table("banned_words")
|
||||
op.drop_index("idx_benchmark_notes_product_id", "benchmark_notes")
|
||||
op.drop_table("benchmark_notes")
|
||||
op.drop_index("idx_products_workspace_id", "products")
|
||||
op.drop_table("products")
|
||||
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""004_flywheel_preference_events
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-06-09
|
||||
Alembic 004: 飞轮信号表
|
||||
preference_events 一期建表写入
|
||||
preference_profile 二期预留,一期不建
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_SIGNAL_TYPE = sa.Enum(
|
||||
"text_select", "image_select", "approve",
|
||||
"reject_with_reason", "regenerate",
|
||||
name="signaltype",
|
||||
)
|
||||
_DATA_OWNERSHIP = sa.Enum("client_data", "platform_asset", name="dataownership")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"preference_events",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("signal_type", _SIGNAL_TYPE, nullable=False),
|
||||
sa.Column("signal_weight", sa.Integer(), nullable=False),
|
||||
sa.Column("candidate_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("signal_meta", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"data_ownership", _DATA_OWNERSHIP,
|
||||
nullable=False, server_default="client_data",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_preference_events_ws_product_created",
|
||||
"preference_events",
|
||||
["workspace_id", "product_id", "created_at"],
|
||||
)
|
||||
# preference_profile 二期预留,一期不建
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_preference_events_ws_product_created", "preference_events")
|
||||
op.drop_table("preference_events")
|
||||
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||
27
backend/alembic/versions/005_product_image_path.py
Normal file
27
backend/alembic/versions/005_product_image_path.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""005_product_image_path — products 表加 image_path 列(产品参考图本地路径)
|
||||
前端图片上传完成后,BE 接口写入此字段;pipeline_io 读取用于生图参考。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "005"
|
||||
down_revision = "004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"image_path",
|
||||
sa.String(512),
|
||||
nullable=True,
|
||||
comment="产品参考图本地文件路径(前端上传后写入,生图时作 reference_images 注入)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("products", "image_path")
|
||||
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""006_image_role_varchar — image_candidates.role 从 enum 改 varchar(32)
|
||||
|
||||
根因:storyboard 角色名仍在演进(hook/product_closeup/ingredient/texture/
|
||||
applied_proof/closer/pain_scene/social_proof/scenario/tutorial…),固定 enum
|
||||
每加一个新角色就要迁一次,且旧 enum 漏了 product_closeup/ingredient 直接导致
|
||||
落库 "Data truncated for column 'role'" → 生图任务无限重试卡死。
|
||||
改 varchar 后落库不再受 enum 约束,角色名由 ImageRole 枚举在应用层定义。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"image_candidates",
|
||||
"role",
|
||||
existing_type=sa.Enum(
|
||||
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||
name="role",
|
||||
),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=False,
|
||||
server_default="hook",
|
||||
comment="分镜角色名(storyboard 输出,应用层 ImageRole 定义,不在 DB 约束)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"image_candidates",
|
||||
"role",
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.Enum(
|
||||
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||
name="role",
|
||||
),
|
||||
existing_nullable=False,
|
||||
server_default="main",
|
||||
)
|
||||
33
backend/alembic/versions/007_need_product_image.py
Normal file
33
backend/alembic/versions/007_need_product_image.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""add need_product_image to generation_tasks
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-08
|
||||
|
||||
本次产品是否入镜开关:True=必须产品参考图(无图禁生成,不降级),False=允许纯文生图。
|
||||
默认 True(铁律:生图优先带产品参考图)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "007"
|
||||
down_revision = "006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"need_product_image",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
comment="本次产品是否入镜:True需产品图(无图禁生成不降级)/False允许纯文生图",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "need_product_image")
|
||||
32
backend/alembic/versions/008_brand_keyword.py
Normal file
32
backend/alembic/versions/008_brand_keyword.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""add brand_keyword to products
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第5环品牌词字段:客户输入,植入文案每条+生图特写图(第2/6张)。
|
||||
brand_keyword 是产品级字段,随产品固定,由客户录入(非AI生成)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"brand_keyword",
|
||||
sa.String(64),
|
||||
nullable=True,
|
||||
comment="品牌词(客户输入,植入文案每条+生图特写图第2/6张)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("products", "brand_keyword")
|
||||
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""add features_json and analyze_status to benchmark_notes
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第2环标杆分析字段:
|
||||
- features_json: 存储8特征结构化分析结果(TEXT JSON)
|
||||
- analyze_status: AI分析状态机 pending/analyzing/done/failed
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"benchmark_notes",
|
||||
sa.Column(
|
||||
"features_json",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="爆款8特征分析结果(JSON),第2环AI解析后写入",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"benchmark_notes",
|
||||
sa.Column(
|
||||
"analyze_status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
comment="AI分析状态: pending/analyzing/done/failed",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("benchmark_notes", "analyze_status")
|
||||
op.drop_column("benchmark_notes", "features_json")
|
||||
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""create fission_tasks table
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第11环裂变引擎:1爆款→N套完整笔记包(非只换文案)。
|
||||
fission_tasks 是裂变任务主表,挂载在工作台内呈现。
|
||||
reference_level: low/mid/high(参考程度)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "010"
|
||||
down_revision = "009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"fission_tasks",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="多租户隔离",
|
||||
),
|
||||
sa.Column(
|
||||
"source_note",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="爆款源笔记内容(文案+图描述,JSON存储)",
|
||||
),
|
||||
sa.Column(
|
||||
"reference_level",
|
||||
sa.String(10),
|
||||
nullable=False,
|
||||
server_default="mid",
|
||||
comment="参考程度: low/mid/high",
|
||||
),
|
||||
sa.Column(
|
||||
"fanout_count",
|
||||
sa.Integer,
|
||||
nullable=False,
|
||||
server_default="3",
|
||||
comment="裂变套数(默认3套)",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
comment="状态: pending/generating/done/failed",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("idx_fission_tasks_workspace_id", "fission_tasks", ["workspace_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("idx_fission_tasks_workspace_id", table_name="fission_tasks")
|
||||
op.drop_table("fission_tasks")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add benchmark_ids and source_fission_id to generation_tasks
|
||||
|
||||
Revision ID: 011
|
||||
Revises: 010
|
||||
Create Date: 2026-06-13
|
||||
|
||||
合并第2环S12+第11环两个需求:
|
||||
- benchmark_ids: 关联标杆笔记ID列表(TEXT存JSON list,第2环标杆分析引用)
|
||||
- source_fission_id: 裂变来源任务ID(NULL=普通任务,非NULL=裂变产出,第11环)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "011"
|
||||
down_revision = "010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"benchmark_ids",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="关联标杆笔记ID列表(JSON list),第2环标杆分析引用",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"source_fission_id",
|
||||
sa.Integer,
|
||||
nullable=True,
|
||||
comment="裂变来源fission_task ID;NULL=普通任务,非NULL=裂变产出(第11环)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "source_fission_id")
|
||||
op.drop_column("generation_tasks", "benchmark_ids")
|
||||
29
backend/alembic/versions/012_product_target_audience.py
Normal file
29
backend/alembic/versions/012_product_target_audience.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""012 products 表加 target_audience 字段
|
||||
|
||||
Revision ID: 012_product_target_audience
|
||||
Revises: 011_task_benchmark_fission_fields
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "012"
|
||||
down_revision = "011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"target_audience",
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment="目标人群,客户输入,透传进文案/生图prompt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("products", "target_audience")
|
||||
Reference in New Issue
Block a user