""" app/models/product.py — products / benchmark_notes / banned_words Alembic 003 业务主体(1/3) products.category 是纯数据字段,禁止任何品类枚举(基石A)。 """ from datetime import datetime from typing import Optional from sqlalchemy import ( BigInteger, DateTime, Enum, ForeignKey, Index, Integer, String, Text, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.constants.enums import BannedWordLevel, ProductImageScene, ProductSource from app.core.database import Base class Product(Base): """产品档案(卖点/违禁词/风格/调性/文案角度/可调prompt/source)""" __tablename__ = "products" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) workspace_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False ) name: Mapped[str] = mapped_column(String(128), nullable=False) # category 是纯数据字段,不在代码里做枚举(基石A) category: Mapped[str | None] = mapped_column(String(64)) source: Mapped[str] = mapped_column( Enum(ProductSource, values_callable=lambda x: [e.value for e in x]), default=ProductSource.CUSTOM, nullable=False, ) selling_points: Mapped[str | None] = mapped_column(Text) # JSON数组 style_tone: Mapped[str | None] = mapped_column(String(128)) text_angles: Mapped[str | None] = mapped_column(Text) # JSON数组,用户设定 custom_prompt: Mapped[str | None] = mapped_column(Text) # 等北哥方案注入 image_path: Mapped[str | None] = mapped_column(String(512)) # 产品参考图路径(前端上传后写入) # 008: 品牌词(客户输入,植入文案每条+生图特写图第2/6张,第5环) brand_keyword: Mapped[str | None] = mapped_column(String(64), comment="品牌词,客户录入,随产品固定") # 012: 目标人群(客户输入,透传进文案/生图 prompt,storyboard.py:52 原恒空现可填) target_audience: Mapped[str | None] = mapped_column(String(128), comment="目标人群,客户输入,透传进文案/生图prompt") is_active: Mapped[bool] = mapped_column(default=True, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now(), nullable=False ) benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship( back_populates="product", lazy="noload" ) images: Mapped[list["ProductImage"]] = relationship( back_populates="product", lazy="selectin", cascade="all, delete-orphan", order_by="ProductImage.sort_order", ) __table_args__ = ( Index("idx_products_workspace_id", "workspace_id"), ) class ProductImage(Base): """产品参考图(R5多图):一产品多张,每张标 scene 场景类型,生图按分镜role选用。 product.image_path 仍保留当主图(向后兼容,零破坏);本表是其超集。 """ __tablename__ = "product_images" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) product_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) path: Mapped[str] = mapped_column(String(512), nullable=False) # 绝对路径,worker 直接 open scene: Mapped[str] = mapped_column( Enum(ProductImageScene, values_callable=lambda x: [e.value for e in x]), default=ProductImageScene.PRIMARY, nullable=False, ) is_primary: Mapped[bool] = mapped_column(default=False, nullable=False) # 主图(同步 product.image_path) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) product: Mapped["Product"] = relationship(back_populates="images") __table_args__ = ( Index("idx_product_images_product_id", "product_id"), ) class BenchmarkNote(Base): """标杆笔记(截图+手填亮点为主通道)""" __tablename__ = "benchmark_notes" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) workspace_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False ) product_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False ) screenshot_url: Mapped[str | None] = mapped_column(Text) highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点 link_url: Mapped[str | None] = mapped_column(Text) # 009: 第2环标杆分析字段 features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果(JSON),AI解析后写入") analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed") created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) product: Mapped["Product"] = relationship(back_populates="benchmark_notes") __table_args__ = ( Index("idx_benchmark_notes_product_id", "product_id"), ) class BannedWord(Base): """违禁词库(三级:auto_fix/soft_warn/hard_block)""" __tablename__ = "banned_words" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) workspace_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False ) word: Mapped[str] = mapped_column(String(64), nullable=False) level: Mapped[str] = mapped_column( Enum(BannedWordLevel, values_callable=lambda x: [e.value for e in x]), nullable=False, ) replacement: Mapped[str | None] = mapped_column(String(128)) updatable: Mapped[bool] = mapped_column(default=True, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), nullable=False ) __table_args__ = ( Index("idx_banned_words_workspace_id", "workspace_id"), )