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:
@@ -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'"
|
||||||
|
)
|
||||||
@@ -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_status(DB 边界兜底状态机)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user