"""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 sqlalchemy import ( BigInteger, Boolean, DateTime, Index, Integer, Numeric, PrimaryKeyConstraint, SmallInteger, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB 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"), )