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

@@ -0,0 +1,71 @@
"""P1-1: 时间列改 TIMESTAMPTZ (created_at / updated_at)
把 16 张表的 created_at/updated_at 由 TIMESTAMP WITHOUT TIME ZONE
改为 TIMESTAMPTZ。既有数据按服务器本地时存的裸时间一律解释为 UTC
`ALTER ... TYPE TIMESTAMPTZ USING <col> AT TIME ZONE 'UTC'`
autogenerate 不会生成 USING 子句手写。downgrade 反向,
TIMESTAMPTZ → naive 时丢弃时区(按 UTC 墙钟取值)。
Revision ID: b1a2c3d4e5f6
Revises: ad2c4c663daf
Create Date: 2026-06-21 00:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "b1a2c3d4e5f6"
down_revision: str | None = "ad2c4c663daf"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# (table, column) — 所有时间列。
_CREATED_AT_TABLES: tuple[str, ...] = (
"users",
"projects",
"skills",
"chapter_digests",
"chapter_reviews",
"chapters",
"characters",
"jobs",
"provider_credentials",
"rules",
"style_fingerprint",
"usage_ledger",
"world_entities",
"chapter_injection",
)
_UPDATED_AT_TABLES: tuple[str, ...] = (
"projects",
"foreshadow",
"jobs",
"chapter_injection",
)
def _columns() -> list[tuple[str, str]]:
cols = [(t, "created_at") for t in _CREATED_AT_TABLES]
cols += [(t, "updated_at") for t in _UPDATED_AT_TABLES]
return cols
def upgrade() -> None:
for table, column in _columns():
op.execute(
f"ALTER TABLE {table} "
f"ALTER COLUMN {column} TYPE TIMESTAMP WITH TIME ZONE "
f"USING {column} AT TIME ZONE 'UTC'"
)
def downgrade() -> None:
for table, column in _columns():
op.execute(
f"ALTER TABLE {table} "
f"ALTER COLUMN {column} TYPE TIMESTAMP WITHOUT TIME ZONE "
f"USING {column} AT TIME ZONE 'UTC'"
)

View File

@@ -0,0 +1,86 @@
"""P1-3 + P2: 热路径索引 + 状态 CHECK 约束
新增:
- 复合索引 ix_chapter_reviews_project_chapter / ix_chapter_digests_project_chapter
- 主访问谓词单列索引 ix_projects_owner_id / ix_usage_ledger_owner_id /
ix_usage_ledger_project_id / ix_rules_project_id
- 部分索引 ix_jobs_status_running (status='running') /
ix_rules_global (project_id IS NULL)
- CHECK 约束 ck_chapters_status / ck_foreshadow_statusDB 边界兜底状态机)
Revision ID: c2b3d4e5f6a7
Revises: b1a2c3d4e5f6
Create Date: 2026-06-21 00:05:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c2b3d4e5f6a7"
down_revision: str | None = "b1a2c3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# --- 复合索引(验收/评审热路径)---
op.create_index(
"ix_chapter_reviews_project_chapter",
"chapter_reviews",
["project_id", "chapter_no", sa.text("created_at DESC")],
unique=False,
)
op.create_index(
"ix_chapter_digests_project_chapter",
"chapter_digests",
["project_id", sa.text("chapter_no DESC")],
unique=False,
)
# --- 主访问谓词单列索引 ---
op.create_index("ix_projects_owner_id", "projects", ["owner_id"], unique=False)
op.create_index("ix_usage_ledger_owner_id", "usage_ledger", ["owner_id"], unique=False)
op.create_index("ix_usage_ledger_project_id", "usage_ledger", ["project_id"], unique=False)
op.create_index("ix_rules_project_id", "rules", ["project_id"], unique=False)
# --- 部分索引 ---
op.create_index(
"ix_jobs_status_running",
"jobs",
["status"],
unique=False,
postgresql_where=sa.text("status = 'running'"),
)
op.create_index(
"ix_rules_global",
"rules",
["id"],
unique=False,
postgresql_where=sa.text("project_id IS NULL"),
)
# --- 状态 CHECK 约束 ---
op.create_check_constraint(
"ck_chapters_status",
"chapters",
"status IN ('draft', 'accepted')",
)
op.create_check_constraint(
"ck_foreshadow_status",
"foreshadow",
"status IN ('OPEN', 'PARTIAL', 'CLOSED', 'OVERDUE')",
)
def downgrade() -> None:
op.drop_constraint("ck_foreshadow_status", "foreshadow", type_="check")
op.drop_constraint("ck_chapters_status", "chapters", type_="check")
op.drop_index("ix_rules_global", table_name="rules")
op.drop_index("ix_jobs_status_running", table_name="jobs")
op.drop_index("ix_rules_project_id", table_name="rules")
op.drop_index("ix_usage_ledger_project_id", table_name="usage_ledger")
op.drop_index("ix_usage_ledger_owner_id", table_name="usage_ledger")
op.drop_index("ix_projects_owner_id", table_name="projects")
op.drop_index("ix_chapter_digests_project_chapter", table_name="chapter_digests")
op.drop_index("ix_chapter_reviews_project_chapter", table_name="chapter_reviews")

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime 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 from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -20,10 +20,13 @@ class UuidPk:
class CreatedAt: 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): class TimestampedMixin(CreatedAt):
updated_at: Mapped[datetime] = mapped_column( 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 ( from sqlalchemy import (
BigInteger, BigInteger,
CheckConstraint,
DateTime,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
LargeBinary, LargeBinary,
Text, Text,
@@ -37,7 +40,7 @@ class User(UuidPk, CreatedAt, Base):
class Project(UuidPk, TimestampedMixin, Base): class Project(UuidPk, TimestampedMixin, Base):
__tablename__ = "projects" __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) title: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(Text) genre: Mapped[str | None] = mapped_column(Text)
logline: 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): class ChapterDigest(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_digests" __tablename__ = "chapter_digests"
__table_args__ = (
Index(
"ix_chapter_digests_project_chapter",
"project_id",
text("chapter_no DESC"),
),
)
project_id: Mapped[uuid.UUID] = mapped_column( project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
) )
@@ -101,7 +111,13 @@ class ChapterDigest(UuidPk, CreatedAt, Base):
class Foreshadow(UuidPk, Base): class Foreshadow(UuidPk, Base):
__tablename__ = "foreshadow" __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( project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True 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("'[]'")) links: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
progress: 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( 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): class Rule(UuidPk, CreatedAt, Base):
__tablename__ = "rules" __tablename__ = "rules"
__table_args__ = (
Index(
"ix_rules_global",
"id",
postgresql_where=text("project_id IS NULL"),
),
)
project_id: Mapped[uuid.UUID | None] = mapped_column( 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) level: Mapped[str] = mapped_column(Text, nullable=False)
content: 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): class Chapter(UuidPk, CreatedAt, Base):
__tablename__ = "chapters" __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( project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
) )
@@ -172,6 +204,14 @@ class ChapterInjection(UuidPk, TimestampedMixin, Base):
class ChapterReview(UuidPk, CreatedAt, Base): class ChapterReview(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_reviews" __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( project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
) )
@@ -217,8 +257,8 @@ class TierRouting(UuidPk, Base):
class UsageLedger(UuidPk, CreatedAt, Base): class UsageLedger(UuidPk, CreatedAt, Base):
__tablename__ = "usage_ledger" __tablename__ = "usage_ledger"
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)
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id")) project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"), index=True)
provider: Mapped[str] = mapped_column(Text, nullable=False) provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False) model: Mapped[str] = mapped_column(Text, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, 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): class Job(UuidPk, TimestampedMixin, Base):
__tablename__ = "jobs" __tablename__ = "jobs"
__table_args__ = (
Index(
"ix_jobs_status_running",
"status",
postgresql_where=text("status = 'running'"),
),
)
project_id: Mapped[uuid.UUID | None] = mapped_column( project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE") ForeignKey("projects.id", ondelete="CASCADE")
) )