feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知

- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
This commit is contained in:
dadao
2026-10-28 13:20:07 +08:00
parent 3d02fe845a
commit 30caaaf1d9
11 changed files with 1849 additions and 49 deletions
+181
View File
@@ -0,0 +1,181 @@
"""M2 工单域:ticket_ext / item_link / notification + 扩宽 item.status 取值。
三个需要注意的点:
1. **item 的 status CHECK 必须重写**。0002 里只允许任务的六个状态,
工单的 `open/accepted/in_progress/pending_verify` 会直接被约束拦下。
这里改成任务 ∪ 工单的并集,而不是按 kind 做条件约束——PostgreSQL 的 CHECK 不能引用其它行,
而"任务不该有工单状态"这件事由状态机(`app/domain/*.py`)保证,不靠数据库。
2. **新表同样开 RLS**。通知是"谁的"、工单附加信息是"哪个租户的",漏一张就等于开了一个洞。
3. **downgrade 会删掉所有工单数据**(kind='ticket' 的 item 及其扩展)。这是有意的:
旧约束容纳不下工单状态,不回删就无法回到 0002。已在函数注释里写明。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled")
def _ws_fk() -> sa.Column:
return sa.Column(
"workspace_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
)
def _user_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column:
return sa.Column(
name,
postgresql.UUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete=ondelete),
nullable=nullable,
)
def _item_fk(name: str, *, ondelete: str, nullable: bool = False) -> sa.Column:
return sa.Column(
name,
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete=ondelete),
nullable=nullable,
)
def upgrade() -> None:
# 1) 放宽 item.status:任务 ∪ 工单
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES))
op.execute(
f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))"
)
# 2) 工单扩展:item_id 既是主键也是外键,天然 1:1
op.create_table(
"ticket_ext",
sa.Column(
"item_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
primary_key=True,
),
_ws_fk(),
_user_fk("requester_id", ondelete="RESTRICT"),
_user_fk("assignee_id", ondelete="RESTRICT"),
_item_fk("parent_item_id", ondelete="SET NULL", nullable=True),
sa.Column("accepted_at", sa.DateTime(timezone=True)),
sa.Column("submitted_at", sa.DateTime(timezone=True)),
sa.Column("verified_at", sa.DateTime(timezone=True)),
_user_fk("verified_by", ondelete="SET NULL", nullable=True),
sa.Column("reject_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("resolution_md", sa.Text, nullable=False, server_default=""),
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"),
sa.CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
)
op.create_index("ix_ticket_ws_assignee", "ticket_ext", ["workspace_id", "assignee_id"])
op.create_index("ix_ticket_ws_requester", "ticket_ext", ["workspace_id", "requester_id"])
# 3) 事项关系(子任务 / 相关)
op.create_table(
"item_link",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
_item_fk("from_item_id", ondelete="CASCADE"),
_item_fk("to_item_id", ondelete="CASCADE"),
sa.Column("link_type", sa.String(16), nullable=False, server_default="related"),
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"),
sa.UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
sa.CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
sa.CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
)
op.create_index("ix_item_link_ws_from", "item_link", ["workspace_id", "from_item_id"])
op.create_index("ix_item_link_ws_to", "item_link", ["workspace_id", "to_item_id"])
# 4) 站内通知
op.create_table(
"notification",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
_ws_fk(),
_user_fk("user_id", ondelete="CASCADE"),
sa.Column("kind", sa.String(32), nullable=False),
_item_fk("item_id", ondelete="CASCADE", nullable=True),
sa.Column("title", sa.String(120), nullable=False),
sa.Column("body", sa.String(300), nullable=False, server_default=""),
sa.Column("read_at", sa.DateTime(timezone=True)),
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"),
)
op.create_index("ix_notification_ws_user_read", "notification", ["workspace_id", "user_id", "read_at"])
op.create_index("ix_notification_ws_created", "notification", ["workspace_id", "created_at"])
for statement in _POLICY_STATEMENTS:
op.execute(statement)
for statement in _GRANT_STATEMENTS:
op.execute(statement)
def downgrade() -> None:
# 旧约束容纳不下工单状态,回退必须先清掉工单数据(含它们的 ticket_ext)。
# 这是破坏性操作,只在回滚迁移时发生;生产环境回滚前请先导出 kanban/ticket 数据。
op.execute("DELETE FROM item WHERE kind = 'ticket'")
for table in TENANT_TABLES_0003:
op.execute(f"DROP POLICY IF EXISTS {table}_isolation ON {table}")
op.drop_index("ix_notification_ws_created", table_name="notification")
op.drop_index("ix_notification_ws_user_read", table_name="notification")
op.drop_table("notification")
op.drop_index("ix_item_link_ws_to", table_name="item_link")
op.drop_index("ix_item_link_ws_from", table_name="item_link")
op.drop_table("item_link")
op.drop_index("ix_ticket_ws_requester", table_name="ticket_ext")
op.drop_index("ix_ticket_ws_assignee", table_name="ticket_ext")
op.drop_table("ticket_ext")
statuses = ",".join(f"'{s}'" for s in TASK_STATUSES)
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
op.execute(
f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))"
)
_POLICY_STATEMENTS: tuple[str, ...] = tuple(
statement
for table in TENANT_TABLES_0003
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_0003)} TO {APP_ROLE};",
)