The product plan is the only source for quota numbers, but the 0001 seed used a second set (agent_chat 20/50, tryon hard 10) and omitted hairstyle, hd_export and report regen. Migration 0003 replaces the seed and renames points_reserved → quota_reserved and bonus_value → bonus_granted so the docs and the schema stop contradicting each other. Effective limit now clips at hard_cap. CI runs the integration slice against the Postgres/Redis services it already starts.
257 lines
12 KiB
Python
Executable File
257 lines
12 KiB
Python
Executable File
"""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"),
|
||
)
|