Files
meida_backend/packages/shared/models.py
T
xsl 2009aa938c fix(billing): make quota seed and column names match the product SSOT
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.
2026-08-14 11:28:35 +08:00

257 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"),
)