From f7004e8d74791fe89e2e326a538f49696c086cb2 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 21 Jun 2026 19:32:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E6=97=B6=E9=97=B4=E5=88=97=20TIMEST?= =?UTF-8?q?AMPTZ=20+=20=E7=83=AD=E8=B7=AF=E5=BE=84=E7=B4=A2=E5=BC=95=20+?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=20CHECK=20=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 往返无漂移。 --- ...a2c3d4e5f6_timestamptz_all_time_columns.py | 71 +++++++++++++++ .../c2b3d4e5f6a7_indexes_and_status_checks.py | 86 +++++++++++++++++++ packages/db/ww_db/base.py | 9 +- packages/db/ww_db/models.py | 61 +++++++++++-- 4 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 packages/db/migrations/versions/b1a2c3d4e5f6_timestamptz_all_time_columns.py create mode 100644 packages/db/migrations/versions/c2b3d4e5f6a7_indexes_and_status_checks.py diff --git a/packages/db/migrations/versions/b1a2c3d4e5f6_timestamptz_all_time_columns.py b/packages/db/migrations/versions/b1a2c3d4e5f6_timestamptz_all_time_columns.py new file mode 100644 index 0000000..e3d7b4e --- /dev/null +++ b/packages/db/migrations/versions/b1a2c3d4e5f6_timestamptz_all_time_columns.py @@ -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 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'" + ) diff --git a/packages/db/migrations/versions/c2b3d4e5f6a7_indexes_and_status_checks.py b/packages/db/migrations/versions/c2b3d4e5f6a7_indexes_and_status_checks.py new file mode 100644 index 0000000..c568ad4 --- /dev/null +++ b/packages/db/migrations/versions/c2b3d4e5f6a7_indexes_and_status_checks.py @@ -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") diff --git a/packages/db/ww_db/base.py b/packages/db/ww_db/base.py index a2399c3..a6727a7 100644 --- a/packages/db/ww_db/base.py +++ b/packages/db/ww_db/base.py @@ -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 ) diff --git a/packages/db/ww_db/models.py b/packages/db/ww_db/models.py index c8d549c..e7ca933 100644 --- a/packages/db/ww_db/models.py +++ b/packages/db/ww_db/models.py @@ -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") )