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
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")
)