feat(shared): cross-cutting plumbing and pure domain layer

Typed pydantic-settings config, structlog JSON logging with request/trace id
propagation and redaction of phone numbers, tokens and face keys, the unified
response envelope and error-code table from 架构文档 §9.2, async SQLAlchemy
session management, ORM models for the eight foundation tables, and the
OpenTelemetry/Prometheus wiring.

packages/domain stays pure logic -- snowflake ids, the quota three-phase
protocol and the tk_task state machine -- so it can be tested without a
database and is held to mypy strict.
This commit is contained in:
xsl
2026-08-14 00:26:39 +08:00
parent da0314fd08
commit 12583e9739
24 changed files with 1717 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
"""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)
limit_value: Mapped[int] = mapped_column(Integer, nullable=False)
bonus_value: 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)
points_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)
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")
)
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"),
)