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