baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
30
backend/app/models/README.md
Normal file
30
backend/app/models/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# app/models/
|
||||
|
||||
SQLAlchemy ORM 模型占位,Alembic 001-004 按顺序建:
|
||||
|
||||
## Alembic 001 — 从banana搬3张(改造)
|
||||
- user.py # users(删credits字段)
|
||||
- login_record.py # login_records
|
||||
- user_preference.py # user_preferences(UI设置偏好)
|
||||
|
||||
## Alembic 002 — 多租户基础(全新)
|
||||
- workspace.py # workspaces
|
||||
- workspace_member.py # workspace_members(user+workspace+角色)
|
||||
- user_api_key.py # user_api_keys(Fernet加密,UNIQUE user_id+workspace_id+provider)
|
||||
|
||||
## Alembic 003 — 业务主体7张(全新)
|
||||
- product.py # products(含text_angles/custom_prompt/source/workspace_id)
|
||||
- benchmark_note.py # benchmark_notes
|
||||
- generation_task.py # generation_tasks(状态机+审核字段平铺)
|
||||
- text_candidate.py # text_candidates(source=ai/import双轨)
|
||||
- image_candidate.py # image_candidates
|
||||
- delivery_package.py # delivery_packages
|
||||
- banned_word.py # banned_words(三级level)
|
||||
|
||||
## Alembic 004 — 飞轮(全新)
|
||||
- preference_event.py # preference_events(含data_ownership字段)
|
||||
|
||||
## 铁律
|
||||
- 所有业务表有 workspace_id 字段
|
||||
- generation_tasks 审核字段平铺:review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at
|
||||
- preference_events.signal_weight 初始默认值:选文案+3/选图+3/通过+5/打回-3/重生成-1
|
||||
24
backend/app/models/__init__.py
Normal file
24
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
app/models/__init__.py — 统一导出所有模型,Alembic autogenerate 能扫到
|
||||
"""
|
||||
|
||||
from app.models.user import LoginRecord, User, UserPreference
|
||||
from app.models.workspace import UserApiKey, Workspace, WorkspaceMember
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
from app.models.task import (
|
||||
DeliveryPackage,
|
||||
GenerationTask,
|
||||
ImageCandidate,
|
||||
TextCandidate,
|
||||
)
|
||||
from app.models.flywheel import AiCallLog, PreferenceEvent
|
||||
from app.models.fission import FissionTask
|
||||
|
||||
__all__ = [
|
||||
"User", "LoginRecord", "UserPreference",
|
||||
"Workspace", "WorkspaceMember", "UserApiKey",
|
||||
"Product", "BenchmarkNote", "BannedWord",
|
||||
"GenerationTask", "TextCandidate", "ImageCandidate",
|
||||
"DeliveryPackage", "PreferenceEvent", "AiCallLog",
|
||||
"FissionTask",
|
||||
]
|
||||
53
backend/app/models/fission.py
Normal file
53
backend/app/models/fission.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
app/models/fission.py — fission_tasks
|
||||
Alembic 010 第11环裂变引擎(1爆款→N套完整笔记包)
|
||||
|
||||
reference_level: low/mid/high(参考程度)
|
||||
status: pending/generating/done/failed
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FissionTask(Base):
|
||||
"""裂变任务:1爆款→N套完整笔记包(第11环),在工作台内呈现。"""
|
||||
__tablename__ = "fission_tasks"
|
||||
|
||||
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
|
||||
)
|
||||
# 爆款源笔记内容(文案+图描述),JSON存储
|
||||
source_note: Mapped[str | None] = mapped_column(
|
||||
Text, comment="爆款源笔记内容(文案+图描述,JSON存储)"
|
||||
)
|
||||
# 参考程度:low/mid/high
|
||||
reference_level: Mapped[str] = mapped_column(
|
||||
String(10), default="mid", nullable=False,
|
||||
comment="参考程度: low/mid/high"
|
||||
)
|
||||
# 裂变套数(默认3套)
|
||||
fanout_count: Mapped[int] = mapped_column(
|
||||
Integer, default=3, nullable=False,
|
||||
comment="裂变套数(默认3套)"
|
||||
)
|
||||
# 状态机
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), default="pending", nullable=False,
|
||||
comment="状态: pending/generating/done/failed"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fission_tasks_workspace_id", "workspace_id"),
|
||||
)
|
||||
97
backend/app/models/flywheel.py
Normal file
97
backend/app/models/flywheel.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
app/models/flywheel.py — preference_events / ai_call_logs
|
||||
Alembic 003(ai_call_logs)+ Alembic 004(preference_events)
|
||||
preference_profile 二期预留,一期不建。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, Enum, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.constants.enums import DataOwnership, SignalType
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PreferenceEvent(Base):
|
||||
"""
|
||||
飞轮信号日志。
|
||||
workspace_id + product_id 都必须有(跨公司隔离 + 按产品分开学)。
|
||||
angle_label 跟着产品的文案角度走,不写死(基石A)。
|
||||
"""
|
||||
__tablename__ = "preference_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("products.id"), nullable=False
|
||||
)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
signal_type: Mapped[str] = mapped_column(
|
||||
Enum(SignalType, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
signal_weight: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
candidate_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||
reason: Mapped[str | None] = mapped_column(Text) # 打回原因原文
|
||||
signal_meta: Mapped[str | None] = mapped_column(Text) # JSON,扩展用
|
||||
data_ownership: Mapped[str] = mapped_column(
|
||||
Enum(DataOwnership, values_callable=lambda x: [e.value for e in x]),
|
||||
default=DataOwnership.CLIENT_DATA, nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_preference_events_ws_product_created",
|
||||
"workspace_id", "product_id", "created_at",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AiCallLog(Base):
|
||||
"""
|
||||
AI 调用记录(usage + 排障基础)。
|
||||
调用失败归因到个人 key(错误码50002)。
|
||||
绝不记录明文 key,只记录 key_id。
|
||||
"""
|
||||
__tablename__ = "ai_call_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
key_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("user_api_keys.id")
|
||||
)
|
||||
task_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id")
|
||||
)
|
||||
provider: Mapped[str | None] = mapped_column(String(32))
|
||||
model: Mapped[str | None] = mapped_column(String(64))
|
||||
call_type: Mapped[str | None] = mapped_column(String(32)) # text/image/analyze
|
||||
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
success: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(String(32))
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ai_call_logs_workspace_user", "workspace_id", "user_id"),
|
||||
Index("idx_ai_call_logs_task_id", "task_id"),
|
||||
)
|
||||
110
backend/app/models/product.py
Normal file
110
backend/app/models/product.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
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, 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"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_products_workspace_id", "workspace_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(String(512))
|
||||
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
|
||||
link_url: Mapped[str | None] = mapped_column(String(512))
|
||||
# 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"),
|
||||
)
|
||||
151
backend/app/models/task.py
Normal file
151
backend/app/models/task.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
app/models/task.py — generation_tasks / text_candidates / image_candidates / delivery_packages
|
||||
Alembic 003 业务主体(2/3)
|
||||
任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
|
||||
eval_score 留 NULL,不接 banana 假评分(基石)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DateTime, Enum, Float, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import (
|
||||
BannedWordStatus, CandidateSource, ImageRole,
|
||||
PackageStatus, ReviewStatus, TaskStatus,
|
||||
)
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class GenerationTask(Base):
|
||||
"""生产任务,审核字段平铺(不建独立审核表)。"""
|
||||
__tablename__ = "generation_tasks"
|
||||
|
||||
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"), nullable=False
|
||||
)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
theme: Mapped[str | None] = mapped_column(String(256))
|
||||
text_count: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
|
||||
track: Mapped[str] = mapped_column(String(16), default="ai", nullable=False)
|
||||
# 本次产品是否入镜:True=必须用产品参考图(无图禁生成,不降级纯文生图);False=允许纯文生图
|
||||
need_product_image: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(TaskStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStatus.PENDING, nullable=False,
|
||||
)
|
||||
mongo_trace_id: Mapped[str | None] = mapped_column(String(24)) # MongoDB trace
|
||||
# ── 审核字段(平铺)──────────────────────────────
|
||||
review_status: Mapped[str | None] = mapped_column(
|
||||
Enum(ReviewStatus, values_callable=lambda x: [e.value for e in x])
|
||||
)
|
||||
reviewer_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("users.id"))
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
reject_reason: Mapped[str | None] = mapped_column(Text)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
# 011: 第2环S12+第11环裂变
|
||||
benchmark_ids: Mapped[str | None] = mapped_column(Text, comment="关联标杆笔记ID列表(JSON list),第2环引用")
|
||||
source_fission_id: Mapped[int | None] = mapped_column(Integer, comment="裂变来源fission_task ID;NULL=普通任务")
|
||||
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
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_generation_tasks_workspace_status", "workspace_id", "status"),
|
||||
)
|
||||
|
||||
|
||||
class TextCandidate(Base):
|
||||
"""文案候选(source=ai/import 区分双轨)。eval_score 留 NULL。"""
|
||||
__tablename__ = "text_candidates"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
source: Mapped[str] = mapped_column(
|
||||
Enum(CandidateSource, values_callable=lambda x: [e.value for e in x]),
|
||||
default=CandidateSource.AI, nullable=False,
|
||||
)
|
||||
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
score_json: Mapped[str | None] = mapped_column(Text) # 五维分 JSON
|
||||
banned_word_status: Mapped[str] = mapped_column(
|
||||
Enum(BannedWordStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=BannedWordStatus.PASS, nullable=False,
|
||||
)
|
||||
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_text_candidates_task_id", "task_id"),
|
||||
)
|
||||
|
||||
|
||||
class ImageCandidate(Base):
|
||||
"""图片候选。eval_score 留 NULL。"""
|
||||
__tablename__ = "image_candidates"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# role 用 varchar 不用 enum:storyboard 角色名仍在演进,约束放应用层 ImageRole
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(32), default=ImageRole.HOOK.value, nullable=False,
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(String(512))
|
||||
strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C(二期)
|
||||
seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号
|
||||
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_image_candidates_task_id", "task_id"),
|
||||
)
|
||||
|
||||
|
||||
class DeliveryPackage(Base):
|
||||
"""达人素材交付包。"""
|
||||
__tablename__ = "delivery_packages"
|
||||
|
||||
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
|
||||
)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(PackageStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=PackageStatus.PENDING, nullable=False,
|
||||
)
|
||||
package_path: Mapped[str | None] = mapped_column(String(512))
|
||||
download_url: Mapped[str | None] = mapped_column(String(512))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
68
backend/app/models/user.py
Normal file
68
backend/app/models/user.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
app/models/user.py — users / login_records / user_preferences
|
||||
Alembic 001:从 banana 搬,删除 credits 字段(架构方案规定)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DateTime, ForeignKey,
|
||||
Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, 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
|
||||
)
|
||||
# 注:删除 banana 的 credits 字段(架构方案规定)
|
||||
|
||||
login_records: Mapped[list["LoginRecord"]] = relationship(
|
||||
back_populates="user", lazy="noload"
|
||||
)
|
||||
|
||||
|
||||
class LoginRecord(Base):
|
||||
__tablename__ = "login_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="login_records")
|
||||
|
||||
|
||||
class UserPreference(Base):
|
||||
"""UI 设置偏好(主题/语言等),不含 API Key。"""
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True, nullable=False,
|
||||
)
|
||||
preferences_json: Mapped[str | None] = mapped_column(Text) # JSON字符串
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
88
backend/app/models/workspace.py
Normal file
88
backend/app/models/workspace.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
app/models/workspace.py — workspaces / workspace_members / user_api_keys
|
||||
Alembic 002:多租户基础,全新建。
|
||||
matrix_accounts 二期预留,一期不建。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, Enum, ForeignKey,
|
||||
Index, String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import UserRole
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
members: Mapped[list["WorkspaceMember"]] = relationship(
|
||||
back_populates="workspace", lazy="noload"
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMember(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
|
||||
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
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
Enum(UserRole, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
workspace: Mapped["Workspace"] = relationship(back_populates="members")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||
)
|
||||
|
||||
|
||||
class UserApiKey(Base):
|
||||
"""
|
||||
个人 API Key(Fernet 加密)。
|
||||
encrypted_key 只存密文,不存 url(token站固定自家站)。
|
||||
UNIQUE(user_id, workspace_id, provider)。
|
||||
"""
|
||||
__tablename__ = "user_api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
encrypted_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
key_last4: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "workspace_id", "provider",
|
||||
name="uq_user_workspace_provider",
|
||||
),
|
||||
Index("idx_user_api_keys_workspace_id", "workspace_id"),
|
||||
)
|
||||
Reference in New Issue
Block a user