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")
|
||||
Reference in New Issue
Block a user