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