Files
DadaoTask/backend/alembic/versions/0002_tasks.py
T
Steven e25a1a4e31
CI / backend (push) Canceled after 0s
feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
2026-10-28 03:48:48 +08:00

203 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M1 任务域:item / tag / item_tag / comment / activity + 行级安全。
RLS 策略与 0001 同一套模式:`workspace_id = app_ws_id()`,读写都校验。
`item` 额外允许"我创建或分配给我"——不,**不做这个特例**:跨租户看别人的任务没有业务含义,
越权面越小越好,列表查询本来就带 workspace_id 过滤。
注意:`pg_trgm` 是 trusted extension(PG13+),库 owner 就能装,所以迁移不需要 superuser。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
_TIMESTAMP_COLUMNS = (
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
def _ws_fk() -> sa.Column:
return sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
)
def upgrade() -> None:
# 中文没有分词器(zhparser 未装),M1 的搜索走 ILIKE 子串匹配,
# trigram 索引能让"标题里找关键字"这条最常用的查询走索引而不是全表扫。
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.create_table(
"item",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
sa.Column("kind", sa.String(16), nullable=False, server_default="task"),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("body_md", sa.Text, nullable=False, server_default=""),
sa.Column("status", sa.String(16), nullable=False, server_default="inbox"),
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
sa.Column(
"creator_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"assignee_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
sa.Column("due_at", sa.DateTime(timezone=True)),
sa.Column("completed_at", sa.DateTime(timezone=True)),
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
*_TIMESTAMP_COLUMNS,
sa.CheckConstraint(
"status IN ('inbox','todo','doing','done','blocked','canceled')",
name="ck_item_status",
),
sa.CheckConstraint("kind IN ('task','ticket','knowledge')", name="ck_item_kind"),
sa.CheckConstraint("priority BETWEEN 0 AND 3", name="ck_item_priority"),
)
op.create_index("ix_item_ws_status_due", "item", ["workspace_id", "status", "due_at"])
op.create_index("ix_item_ws_assignee", "item", ["workspace_id", "assignee_id", "status"])
op.create_index("ix_item_ws_updated", "item", ["workspace_id", "updated_at"])
op.create_index("ix_item_ws_sort", "item", ["workspace_id", "status", "sort_order"])
op.create_table(
"tag",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
sa.Column("name", sa.String(32), nullable=False),
sa.Column("color", sa.String(16), nullable=False, server_default=""),
*_TIMESTAMP_COLUMNS,
sa.UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"),
)
op.create_index("ix_tag_ws", "tag", ["workspace_id", "name"])
op.create_table(
"item_tag",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"tag_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tag.id", ondelete="CASCADE"),
nullable=False,
),
*_TIMESTAMP_COLUMNS,
sa.UniqueConstraint("item_id", "tag_id", name="uq_item_tag"),
)
op.create_index("ix_item_tag_ws_tag", "item_tag", ["workspace_id", "tag_id"])
op.create_table(
"comment",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"author_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("body_md", sa.Text, nullable=False, server_default=""),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
*_TIMESTAMP_COLUMNS,
)
op.create_index("ix_comment_ws_item_created", "comment", ["workspace_id", "item_id", "created_at"])
op.create_table(
"activity",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"actor_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="SET NULL"),
),
sa.Column("action", sa.String(32), nullable=False),
sa.Column("payload", postgresql.JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_activity_ws_item_created", "activity", ["workspace_id", "item_id", "created_at"])
# 标题三元组索引:ILIKE '%关键字%' 能用到它
op.execute(
"CREATE INDEX ix_item_title_trgm ON item USING gin (title gin_trgm_ops)"
)
for statement in _POLICY_STATEMENTS:
op.execute(statement)
for statement in _GRANT_STATEMENTS:
op.execute(statement)
def downgrade() -> None:
# 先摘策略:策略里引用了这些表本身,表没了策略语句就悬空
for table in TENANT_TABLES_0002:
op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}")
op.execute("DROP INDEX IF EXISTS ix_item_title_trgm")
for table in (
"activity",
"comment",
"item_tag",
"tag",
"item",
):
op.drop_table(table)
# pg_trgm 是库级共享资源,别在这里删(其它模块可能也在用)
_POLICY_STATEMENTS: tuple[str, ...] = tuple(
statement
for table in TENANT_TABLES_0002
for statement in (
f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;",
f"""
CREATE POLICY {table}_isolation ON {table}
USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id());
""",
)
)
_GRANT_STATEMENTS: tuple[str, ...] = (
f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0002)} TO {APP_ROLE};",
)