CI / backend (push) Canceled after 0s
- 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 按已验证口径重写
149 lines
6.2 KiB
Python
149 lines
6.2 KiB
Python
"""任务域模型(M1):item / tag / item_tag / comment / activity。
|
||
|
||
约定与 M0 一致:uuid 主键、通用列、`workspace_id NOT NULL`。
|
||
|
||
两个刻意的取舍:
|
||
- **M1 只有一个负责人 `assignee_id`**,不做 `item_assignee` 多对多。M1 是"自己的待办",
|
||
多人协作在 M2 工单里才真正需要;提前建多对多表和对应 UI 是过度设计,等真要用再迁移。
|
||
- **`kind` 列现在就加上**(默认 `task`)。M2 的工单、M3 的知识都是同一条 item 流,
|
||
现在加一列只是默认值,将来加 `ticket_ext` / `knowledge_doc` 时不用改主表。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import (
|
||
DateTime,
|
||
ForeignKey,
|
||
Index,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
func,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
from .models import Base, Timestamps, new_uuid
|
||
|
||
ITEM_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||
|
||
|
||
class Item(Base, Timestamps):
|
||
"""任务/工单/知识的统一载体。M1 只用 `kind='task'`。"""
|
||
|
||
__tablename__ = "item"
|
||
__table_args__ = (
|
||
Index("ix_item_ws_status_due", "workspace_id", "status", "due_at"),
|
||
Index("ix_item_ws_assignee", "workspace_id", "assignee_id", "status"),
|
||
Index("ix_item_ws_updated", "workspace_id", "updated_at"),
|
||
Index("ix_item_ws_sort", "workspace_id", "status", "sort_order"),
|
||
)
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="task")
|
||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||
body_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="inbox")
|
||
priority: Mapped[int] = mapped_column(nullable=False, default=0)
|
||
creator_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||
)
|
||
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||
)
|
||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
|
||
|
||
class Tag(Base, Timestamps):
|
||
__tablename__ = "tag"
|
||
__table_args__ = (
|
||
UniqueConstraint("workspace_id", "name", name="uq_tag_ws_name"),
|
||
Index("ix_tag_ws", "workspace_id", "name"),
|
||
)
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
name: Mapped[str] = mapped_column(String(32), nullable=False)
|
||
color: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||
|
||
|
||
class ItemTag(Base, Timestamps):
|
||
__tablename__ = "item_tag"
|
||
__table_args__ = (
|
||
UniqueConstraint("item_id", "tag_id", name="uq_item_tag"),
|
||
Index("ix_item_tag_ws_tag", "workspace_id", "tag_id"),
|
||
)
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
tag_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("tag.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
|
||
|
||
class Comment(Base, Timestamps):
|
||
__tablename__ = "comment"
|
||
__table_args__ = (Index("ix_comment_ws_item_created", "workspace_id", "item_id", "created_at"),)
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||
)
|
||
body_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
|
||
|
||
class Activity(Base):
|
||
"""事件流:item 的每一次实质变更都落一条。
|
||
|
||
这是"这条任务怎么走到今天的"唯一真相源,M4 的 LLM 沉淀、M3 的知识都从这里取料。
|
||
只增不改,所以没有 version / updated_at。
|
||
"""
|
||
|
||
__tablename__ = "activity"
|
||
__table_args__ = (
|
||
Index("ix_activity_ws_item_created", "workspace_id", "item_id", "created_at"),
|
||
)
|
||
|
||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||
)
|
||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||
payload: Mapped[dict | None] = mapped_column(JSONB)
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||
)
|
||
|
||
|
||
TASK_TENANT_TABLES: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|