feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
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 按已验证口径重写
This commit is contained in:
Steven
2026-10-28 03:48:48 +08:00
parent d390b7b58a
commit e25a1a4e31
19 changed files with 1959 additions and 31 deletions
+148
View File
@@ -0,0 +1,148 @@
"""任务域模型(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")