"""SQLAlchemy 2.0 ORM models. Scope of this scaffold: only the tables needed to prove the vertical slice (架构文档 §6.2 identity / billing / task / 观测与成本). All remaining domains (pf_ / rp_ / kb_ / ag_ / tr_ / cp_) are intentionally absent — see packages/*/README.md for what still has to be built. Conventions enforced here (架构文档 §6.1): 1. 表名带域前缀 (`id_` / `bl_` / `tk_` / `ob_`),为将来拆库预留边界。 2. 主键统一雪花 ID (bigint),不用自增、不用 UUID。 3. 涉及配额的表必须有幂等键与流水。 4. JSONB 只用于「结构会演进且不需要索引全部字段」的场景。 """ from __future__ import annotations from datetime import datetime from decimal import Decimal from typing import Any from pgvector.sqlalchemy import Vector from sqlalchemy import ( BigInteger, Boolean, Computed, DateTime, Index, Integer, Numeric, PrimaryKeyConstraint, SmallInteger, String, Text, UniqueConstraint, func, text, ) from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): """Declarative base; ``Base.metadata`` is Alembic's ``target_metadata``.""" # --------------------------------------------------------------------------- # identity 域 # --------------------------------------------------------------------------- class User(Base): __tablename__ = "id_user" user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") nickname: Mapped[str | None] = mapped_column(String(64)) avatar_key: Mapped[str | None] = mapped_column(String(255)) register_source: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = (Index("ix_id_user_status", "status"),) class UserIdentity(Base): """External identity mapping. UNIQUE(provider, union_id) per 架构文档 §6.2.""" __tablename__ = "id_user_identity" identity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) provider: Mapped[str] = mapped_column(String(32), nullable=False) union_id: Mapped[str] = mapped_column(String(128), nullable=False) open_id: Mapped[str | None] = mapped_column(String(128)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("provider", "union_id", name="uq_id_user_identity_provider_union"), Index("ix_id_user_identity_user", "user_id"), ) class Consent(Base): """CR-01 留证:人脸的单独同意必须独立成行,不得混入总协议。""" __tablename__ = "id_consent" consent_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) consent_type: Mapped[str] = mapped_column(String(32), nullable=False) doc_version: Mapped[str] = mapped_column(String(32), nullable=False) granted_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) client_ip: Mapped[str | None] = mapped_column(String(64)) ua: Mapped[str | None] = mapped_column(String(512)) __table_args__ = (Index("ix_id_consent_user_type", "user_id", "consent_type"),) # --------------------------------------------------------------------------- # billing 域(V1:配额与计量,无支付) # --------------------------------------------------------------------------- class QuotaRule(Base): """FR-BILL-04 配额规则配置化:改配置不发版。""" __tablename__ = "bl_quota_rule" quota_key: Mapped[str] = mapped_column(String(64), primary_key=True) period_type: Mapped[str] = mapped_column(String(16), nullable=False) # day/month/lifetime base_limit: Mapped[int] = mapped_column(Integer, nullable=False) task_bonus_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hard_cap: Mapped[int] = mapped_column(Integer, nullable=False) description: Mapped[str | None] = mapped_column(String(255)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class Quota(Base): """周期配额计量:按 ``period_key`` 自然重置,无需定时任务清零。""" __tablename__ = "bl_quota" user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) quota_key: Mapped[str] = mapped_column(String(64), nullable=False) period_key: Mapped[str] = mapped_column(String(32), nullable=False) used: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 本周期创建时从规则拷下的 base_limit。规则中途被改,本周期仍按快照判定。 limit_value: Mapped[int] = mapped_column(Integer, nullable=False) bonus_granted: Mapped[int] = mapped_column(Integer, nullable=False, default=0) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( PrimaryKeyConstraint("user_id", "quota_key", "period_key", name="pk_bl_quota"), ) class Ledger(Base): """流水是真相,余额是缓存 (架构文档 §6.1 原则 4 / §6.2 bl_ledger). ``tx_type`` 在 V1 只用 system_grant / consume / refund;V2 增加 purchase / gift —— 这是 V1→V2 免数据迁移的关键,不要拆表。 ``status`` 承载 §7.4 的预扣三段式:reserved → committed / refunded。 """ __tablename__ = "bl_ledger" tx_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) delta: Mapped[int] = mapped_column(Integer, nullable=False) tx_type: Mapped[str] = mapped_column(String(24), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="committed") reason: Mapped[str | None] = mapped_column(String(128)) ref_type: Mapped[str | None] = mapped_column(String(32)) ref_id: Mapped[str | None] = mapped_column(String(64)) quota_key: Mapped[str | None] = mapped_column(String(64)) period_key: Mapped[str | None] = mapped_column(String(32)) balance_after: Mapped[int] = mapped_column(Integer, nullable=False, default=0) idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("idempotency_key", name="uq_bl_ledger_idempotency_key"), Index("ix_bl_ledger_user_created", "user_id", "created_at"), ) # --------------------------------------------------------------------------- # task 域(统一任务中心) # --------------------------------------------------------------------------- class Task(Base): """全系统唯一的长耗时任务视图,前端任务中心直接读它 (架构文档 §6.2)。""" __tablename__ = "tk_task" task_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) task_type: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False) progress: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) stage_text: Mapped[str | None] = mapped_column(String(128)) idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) result: Mapped[dict[str, Any] | None] = mapped_column(JSONB) error_code: Mapped[int | None] = mapped_column(Integer) error_message: Mapped[str | None] = mapped_column(String(512)) retry_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) quota_reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0) reserve_tx_id: Mapped[int | None] = mapped_column(BigInteger) submitted_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) eta_seconds: Mapped[int | None] = mapped_column(Integer) __table_args__ = ( UniqueConstraint("idempotency_key", name="uq_tk_task_idempotency_key"), Index("ix_tk_task_user_status", "user_id", "status"), ) # --------------------------------------------------------------------------- # 观测与成本 # --------------------------------------------------------------------------- class AiCall(Base): """成本归因的唯一数据源 (架构文档 §13, FR-BILL-08). **不允许「先上线后补埋点」** —— V1 不收费,成本是唯一财务变量,且这张表 是 V2 定价的唯一数据来源。每一次 provider 调用(含失败与结构化重试)都写 一行。生产环境按月分区。 """ __tablename__ = "ob_ai_call" call_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int | None] = mapped_column(BigInteger) scene: Mapped[str] = mapped_column(String(48), nullable=False) provider: Mapped[str] = mapped_column(String(32), nullable=False) model: Mapped[str] = mapped_column(String(64), nullable=False) # Prompt 版本:架构文档 §8.1「可复现」要求记录,§6.2 的列清单漏了,这里补上。 prompt_id: Mapped[str | None] = mapped_column(String(64)) prompt_version: Mapped[str | None] = mapped_column(String(16)) tokens_in: Mapped[int] = mapped_column(Integer, nullable=False, default=0) tokens_out: Mapped[int] = mapped_column(Integer, nullable=False, default=0) tokens_cached: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 思考模式 token 单独记,不并入 tokens_out:它按输出价计费,混在一起就看不出 # 开关思考模式的成本差异,而这正是 T-P1-24a 要长期盯的数。 tokens_reasoning: Mapped[int] = mapped_column(Integer, nullable=False, default=0) latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) cost_cents: Mapped[Decimal] = mapped_column( Numeric(14, 6), nullable=False, default=Decimal("0") ) # 该次调用适用的峰谷档位。没有它就无法事后复核 cost_cents —— # 单价随时刻变化,事后按「当前价」回算会得出不同结果 (T-P1-25c)。 rate_tier: Mapped[str | None] = mapped_column(String(16)) success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) error_code: Mapped[str | None] = mapped_column(String(64)) error_message: Mapped[str | None] = mapped_column(Text) ref_type: Mapped[str | None] = mapped_column(String(32)) ref_id: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( Index("ix_ob_ai_call_created", "created_at"), Index("ix_ob_ai_call_scene_created", "scene", "created_at"), Index("ix_ob_ai_call_user_created", "user_id", "created_at"), ) class UserSession(Base): """refresh token 的服务端会话(架构文档 §6.2 id_session,FR-ACC-05). 刻意叫 UserSession 而不是 Session:避免与 SQLAlchemy 的 AsyncSession 混淆。 refresh token 本体是 opaque 随机串(不出现在任何存储里),库里只存 sha256 哈希 —— 泄漏数据库不等于泄漏令牌。轮换 = 旧行 revoked + 新行,同一事务。 """ __tablename__ = "id_session" session_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) refresh_token_hash: Mapped[str] = mapped_column(String(64), nullable=False) expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("refresh_token_hash", name="uq_id_session_token_hash"), Index("ix_id_session_user", "user_id"), ) class ObEvent(Base): """客户端埋点事件(架构文档 §6.2 ob_event,FR-SYS-06). 架构原文「按月分区」—— V1 先普通表(与 ob_ai_call 同样处理), 数据量上来后再分区,先不预支复杂度。 """ __tablename__ = "ob_event" event_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) event_name: Mapped[str] = mapped_column(String(64), nullable=False) props: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) client_ts: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) server_ts: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( Index("ix_ob_event_name_ts", "event_name", "server_ts"), Index("ix_ob_event_user_ts", "user_id", "server_ts"), ) # --------------------------------------------------------------------------- # kb 域(知识库,架构文档 §6.2) # --------------------------------------------------------------------------- class KbEntity(Base): """12 类实体统一表:attrs JSONB 承载类目差异化字段(架构 §6.2). 状态机 draft→reviewing→published→archived 由 service 层校验; published 实体不可直接改(version 递增需回 draft 重走评审)。 """ __tablename__ = "kb_entity" entity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) entity_type: Mapped[str] = mapped_column(String(32), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False) attrs: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) source: Mapped[str | None] = mapped_column(String(255)) license: Mapped[str | None] = mapped_column(String(64)) updated_by: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("entity_type", "name", name="uq_kb_entity_type_name"), Index("ix_kb_entity_type_status", "entity_type", "status"), ) class KbRelation(Base): """邻接表 + 递归 CTE 做 1~3 跳(TD-09:不上 Neo4j).""" __tablename__ = "kb_relation" relation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) from_id: Mapped[int] = mapped_column(BigInteger, nullable=False) rel_type: Mapped[str] = mapped_column(String(64), nullable=False) to_id: Mapped[int] = mapped_column(BigInteger, nullable=False) weight: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1")) confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1")) evidence: Mapped[str | None] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("from_id", "rel_type", "to_id", name="uq_kb_relation_triple"), Index("ix_kb_relation_to", "to_id"), ) class KbRule(Base): """决策表(TD-10 自研规则引擎)。conditions/effects 为 JSONB DSL.""" __tablename__ = "kb_rule" rule_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) rule_key: Mapped[str] = mapped_column(String(64), nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") priority: Mapped[int] = mapped_column(Integer, nullable=False, default=60) conditions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) effects: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) is_hard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) confidence: Mapped[Decimal] = mapped_column( Numeric(4, 3), nullable=False, default=Decimal("0.9") ) evidence: Mapped[str | None] = mapped_column(String(255)) author: Mapped[str | None] = mapped_column(String(64)) reviewer: Mapped[str | None] = mapped_column(String(64)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("rule_key", name="uq_kb_rule_key"), Index("ix_kb_rule_status_priority", "status", "priority"), ) class KbChunk(Base): """向量 + 全文双索引,支撑混合检索(架构 §8.3). embedding_model/version 必填:没有版本号的向量不可重跑(T-P2-03)。 tsv 是生成列——写入时不要手工填。 """ __tablename__ = "kb_chunk" chunk_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False) chunk_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text") text: Mapped[str] = mapped_column(Text, nullable=False) embedding = mapped_column(Vector(1024), nullable=False) embedding_model: Mapped[str] = mapped_column(String(64), nullable=False) embedding_version: Mapped[str] = mapped_column(String(16), nullable=False) tsv = mapped_column( TSVECTOR, Computed("to_tsvector('simple', text)", persisted=True), nullable=False ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (Index("ix_kb_chunk_entity", "entity_id"),) class KbAsset(Base): """图像资产。tryon_ready 是换装闭环的关键标志位(架构 §6.2).""" __tablename__ = "kb_asset" asset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False) asset_type: Mapped[str] = mapped_column(String(32), nullable=False) cos_key: Mapped[str] = mapped_column(String(255), nullable=False) spec_check: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) tryon_ready: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") license: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( Index("ix_kb_asset_entity", "entity_id"), Index("ix_kb_asset_tryon_ready", "tryon_ready", postgresql_where=text("tryon_ready")), ) class KbColor(Base): """自建色板(TD-18:规避「四季色彩/CMB」商标)。Lab 用于 ΔE 数值匹配.""" __tablename__ = "kb_color" color_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) season_code: Mapped[str] = mapped_column(String(16), nullable=False) role: Mapped[str] = mapped_column(String(8), nullable=False) lab_l: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False) lab_a: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False) lab_b: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False) hex: Mapped[str] = mapped_column(String(7), nullable=False) name_cn: Mapped[str] = mapped_column(String(64), nullable=False) __table_args__ = (Index("ix_kb_color_season", "season_code"),) class KbGap(Base): """知识缺口埋点:无检索结果的查询,驱动内容生产(T-P2-06).""" __tablename__ = "kb_gap" gap_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) query_text: Mapped[str] = mapped_column(Text, nullable=False) filter_ctx: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) hit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) first_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) last_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (Index("ix_kb_gap_query", "query_text"),) # --------------------------------------------------------------------------- # report 域(美学报告,架构文档 §6.2 / §7.1) # --------------------------------------------------------------------------- class RpReport(Base): """报告主表。result JSONB 同时承载流水线中间态与最终产物(按 stage 键)—— 阶段逐段落库是 T-P3-09「失败不需重新上传」的结构基础。""" __tablename__ = "rp_report" report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) tier: Mapped[str] = mapped_column(String(8), nullable=False, default="basic") status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) model_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) content_code: Mapped[str | None] = mapped_column(String(32)) rendered_asset_key: Mapped[str | None] = mapped_column(String(255)) cost_cents: Mapped[Decimal] = mapped_column( Numeric(14, 6), nullable=False, default=Decimal("0") ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = (Index("ix_rp_report_user_created", "user_id", "created_at"),) class RpInput(Base): """报告输入。derived_features 是视觉分析的中间态:重试/重新生成时 已存在则跳过视觉阶段(T-P3-09)。只存派生特征与元信息,不存原图(CR-02)。""" __tablename__ = "rp_input" input_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) report_id: Mapped[int] = mapped_column(BigInteger, nullable=False) questionnaire: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) photo_meta: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) derived_features: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) __table_args__ = (UniqueConstraint("report_id", name="uq_rp_input_report"),) class RpShare(Base): """分享链接:可失效、可撤回(V1 只建结构,路由后续任务)。""" __tablename__ = "rp_share" share_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) report_id: Mapped[int] = mapped_column(BigInteger, nullable=False) share_token: Mapped[str] = mapped_column(String(64), nullable=False) expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (UniqueConstraint("share_token", name="uq_rp_share_token"),)