fix(db): 时间列 TIMESTAMPTZ + 热路径索引 + 状态 CHECK 约束

P1-1 base.py 时间列改 timezone=True;迁移把 16 表 created_at/updated_at
  ALTER 为 TIMESTAMPTZ(手写 USING ... AT TIME ZONE 'UTC')。
P1-3 新增 8 个索引(chapter_reviews/chapter_digests 复合、owner_id、
  usage_ledger、rules + jobs/rules 部分索引)。
P2 chapters/foreshadow status CHECK 约束。
迁移链 ad2c4c663daf → b1a2c3d4e5f6 → c2b3d4e5f6a7,upgrade/downgrade 往返无漂移。
This commit is contained in:
Yaojia Wang
2026-06-21 19:32:49 +02:00
parent 345cc73965
commit f7004e8d74
4 changed files with 217 additions and 10 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Uuid, func, text
from sqlalchemy import DateTime, Uuid, func, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -20,10 +20,13 @@ class UuidPk:
class CreatedAt:
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
# TIMESTAMPTZ服务端 `func.now()` 兜底,无 client 端 default统一存 UTC
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class TimestampedMixin(CreatedAt):
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -11,7 +11,10 @@ from typing import Any
from sqlalchemy import (
BigInteger,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
Text,
@@ -37,7 +40,7 @@ class User(UuidPk, CreatedAt, Base):
class Project(UuidPk, TimestampedMixin, Base):
__tablename__ = "projects"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(Text)
logline: Mapped[str | None] = mapped_column(Text)
@@ -92,6 +95,13 @@ class Outline(UuidPk, Base):
class ChapterDigest(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_digests"
__table_args__ = (
Index(
"ix_chapter_digests_project_chapter",
"project_id",
text("chapter_no DESC"),
),
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -101,7 +111,13 @@ class ChapterDigest(UuidPk, CreatedAt, Base):
class Foreshadow(UuidPk, Base):
__tablename__ = "foreshadow"
__table_args__ = (UniqueConstraint("project_id", "code"),)
__table_args__ = (
UniqueConstraint("project_id", "code"),
CheckConstraint(
"status IN ('OPEN', 'PARTIAL', 'CLOSED', 'OVERDUE')",
name="ck_foreshadow_status",
),
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -116,7 +132,10 @@ class Foreshadow(UuidPk, Base):
links: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
progress: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
updated_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=text("now()"), onupdate=text("now()")
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
onupdate=text("now()"),
)
@@ -132,8 +151,15 @@ class StyleFingerprint(UuidPk, CreatedAt, Base):
class Rule(UuidPk, CreatedAt, Base):
__tablename__ = "rules"
__table_args__ = (
Index(
"ix_rules_global",
"id",
postgresql_where=text("project_id IS NULL"),
),
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
level: Mapped[str] = mapped_column(Text, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
@@ -141,7 +167,13 @@ class Rule(UuidPk, CreatedAt, Base):
class Chapter(UuidPk, CreatedAt, Base):
__tablename__ = "chapters"
__table_args__ = (UniqueConstraint("project_id", "chapter_no", "version"),)
__table_args__ = (
UniqueConstraint("project_id", "chapter_no", "version"),
CheckConstraint(
"status IN ('draft', 'accepted')",
name="ck_chapters_status",
),
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -172,6 +204,14 @@ class ChapterInjection(UuidPk, TimestampedMixin, Base):
class ChapterReview(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_reviews"
__table_args__ = (
Index(
"ix_chapter_reviews_project_chapter",
"project_id",
"chapter_no",
text("created_at DESC"),
),
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
@@ -217,8 +257,8 @@ class TierRouting(UuidPk, Base):
class UsageLedger(UuidPk, CreatedAt, Base):
__tablename__ = "usage_ledger"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"))
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), index=True)
provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -246,6 +286,13 @@ class Skill(UuidPk, CreatedAt, Base):
class Job(UuidPk, TimestampedMixin, Base):
__tablename__ = "jobs"
__table_args__ = (
Index(
"ix_jobs_status_running",
"status",
postgresql_where=text("status = 'running'"),
),
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)