Files
hunjing/backend/app/models.py
T
chenxin 28127949db ✨ feat: 数据库基线与通用 DTO(WP-01 第 3、4 步)
- 通用 DTO:Money(最小货币单位整数)、CursorPage 泛型、9 个平台 ID Annotated 类型(前缀+UUIDv7/v4 正则,与 openapi 契约对齐)
- db 基础:naming_convention Base(pk_/uq_/ix_/fk_,AGENTS 8.6)+ async engine + 事务作用域 session
- 首批 6 张表 typed mapping:user/personal_domain/session/service_account/audit_event/outbox_event;Session ID 为 CSPRNG opaque;审计与 Outbox 不可变
- Alembic async 迁移(含表注释);docker 起 hunjing-postgres 并 upgrade 验证:7 表、约束/索引命名全部符合约定
- 按 database-design skill 清单交叉检查:外键索引全覆盖(复合唯一最左前缀)、审计聚合时间线与 Outbox 投递扫描均为专用复合索引
- 集成测试 8 项(唯一约束、FK restrict/cascade、检查约束、同事务提交)连 PG 测试库运行;pytest 15 项、ruff、mypy strict 全绿
2026-09-10 10:00:17 +08:00

141 lines
5.9 KiB
Python
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.
"""首批领域模型:身份、会话、服务账号、审计与 Outbox(WP-01)。
- 主键为带前缀的 UUIDv7 文本(ADR-015);Session ID 为 CSPRNG opaque(秘密不用 UUID)。
- 命名约定见 app/db/base.py(pk_/uq_/ix_/fk_,AGENTS.md 8.6)。
- AuditEvent 与 OutboxEvent 不可变:无 updated_at,修正写新记录(契约第 4.4 节)。
"""
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.db.base import Base
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_"))
email: Mapped[str] = mapped_column(String, nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(String, nullable=False)
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(), onupdate=func.now()
)
class PersonalDomain(Base):
__tablename__ = "personal_domain"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("domain_")
)
user_id: Mapped[str] = mapped_column(
ForeignKey("user.id", ondelete="restrict"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
# 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议
data_region: Mapped[str] = mapped_column(String, nullable=False, default="default")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Session(Base):
"""Web 会话:ID 为 CSPRNG opaque value,数据库只存校验值(ADR-015)。"""
__tablename__ = "session"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
ForeignKey("user.id", ondelete="cascade"), nullable=False, index=True
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ServiceAccount(Base):
"""自动化账号:API key 明文只显示一次,只保存哈希、前缀、scope 与撤销状态。"""
__tablename__ = "service_account"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("service_account_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
name: Mapped[str] = mapped_column(String, nullable=False)
key_prefix: Mapped[str] = mapped_column(String, nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String, nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (Index("uq_service_account_domain_name", "domain_id", "name", unique=True),)
class AuditEvent(Base):
"""不可变审计链:状态与 Outbox 在同一事务提交(契约第 4.4 节)。"""
__tablename__ = "audit_event"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("audit_"))
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
actor_type: Mapped[str] = mapped_column(String, nullable=False)
actor_id: Mapped[str] = mapped_column(String, nullable=False)
action: Mapped[str] = mapped_column(String, nullable=False)
aggregate_type: Mapped[str] = mapped_column(String, nullable=False)
aggregate_id: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
trace_id: Mapped[str | None] = mapped_column(String, nullable=True)
__table_args__ = (
# 聚合时间线是审计的主要查询路径(最左前缀覆盖聚合定位)
Index("ix_audit_event_aggregate", "aggregate_type", "aggregate_id", "occurred_at"),
Index("ix_audit_event_occurred_at", "occurred_at"),
)
class OutboxEvent(Base):
"""Transactional Outbox:已提交领域事实的可靠投递队列。"""
__tablename__ = "outbox_event"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("event_"))
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
aggregate_type: Mapped[str] = mapped_column(String, nullable=False)
aggregate_id: Mapped[str] = mapped_column(String, nullable=False)
event_type: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(nullable=False, default=0)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'delivered', 'failed')",
name="status_values",
),
# 投递循环按状态+时间扫描待处理事件
Index("ix_outbox_event_status_occurred", "status", "occurred_at"),
Index("ix_outbox_event_aggregate", "aggregate_type", "aggregate_id"),
)