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
+34 -46
View File
@@ -28,7 +28,9 @@ from ..domain.task import (
)
from ..errors import AppError, ErrorCode
from ..models_task import Activity, Comment, Item, ItemTag, Tag
from ..models_ticket import TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tag_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
@@ -76,36 +78,20 @@ class TagIn(BaseModel):
color: str = Field(default="", max_length=16)
# ── 序列化 ──────────────────────────────────────────────────────────────
def _tag_out(tag: Tag) -> dict[str, Any]:
return {"id": str(tag.id), "name": tag.name, "color": tag.color}
def _item_out(item: Item, tags: list[Tag] | None = None) -> dict[str, Any]:
return {
"id": str(item.id),
"kind": item.kind,
"title": item.title,
"body_md": item.body_md,
"status": item.status,
"priority": item.priority,
"creator_id": str(item.creator_id),
"assignee_id": str(item.assignee_id) if item.assignee_id else None,
"due_at": item.due_at,
"completed_at": item.completed_at,
"sort_order": item.sort_order,
"deleted_at": item.deleted_at,
"tags": [_tag_out(t) for t in (tags or [])],
"created_at": item.created_at,
"updated_at": item.updated_at,
"version": item.version,
}
# ── 序列化(与工单接口共用一份,两份拼装迟早会不一致)─────────────────────
_tag_out = tag_out
_item_out = item_out
# ── 内部工具 ────────────────────────────────────────────────────────────
async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID) -> Item:
async def _load_item(
session: AsyncSession,
ws_id: uuid.UUID,
item_id: uuid.UUID,
kinds: tuple[str, ...] = ("task",),
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind != "task":
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 不是任务,对外一律"不存在",不泄漏别人有没有这条数据
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
return item
@@ -114,20 +100,7 @@ async def _load_item(session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
async def _tags_of(
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
) -> dict[uuid.UUID, list[Tag]]:
if not item_ids:
return {}
rows = (
await session.execute(
select(ItemTag.item_id, Tag)
.join(Tag, Tag.id == ItemTag.tag_id)
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(item_ids))
.order_by(Tag.name)
)
).all()
grouped: dict[uuid.UUID, list[Tag]] = {}
for item_id, tag in rows:
grouped.setdefault(item_id, []).append(tag)
return grouped
return await tags_of(session, ws_id, item_ids)
def _normalize_tag_names(names: list[str]) -> list[str]:
@@ -415,9 +388,9 @@ async def board(
)
@router.get("/{item_id}", summary="任务详情(含最近 50 条事件)")
@router.get("/{item_id}", summary="事项详情(任务或工单,含最近 50 条事件)")
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
tags = await _tags_of(session, ctx.ws_id, [item.id])
activities = (
(
@@ -442,6 +415,20 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
}
for a in activities
]
if item.kind == "ticket":
ext = await session.get(TicketExt, item.id)
if ext is not None:
data["ticket"] = {
"requester_id": str(ext.requester_id),
"assignee_id": str(ext.assignee_id),
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
"accepted_at": ext.accepted_at,
"submitted_at": ext.submitted_at,
"verified_at": ext.verified_at,
"verified_by": str(ext.verified_by) if ext.verified_by else None,
"reject_count": ext.reject_count,
"resolution_md": ext.resolution_md,
}
return ok(data)
@@ -556,11 +543,12 @@ async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep)
)
@router.patch("/{item_id}", summary="修改任务字段(乐观锁)")
@router.patch("/{item_id}", summary="修改事项字段(乐观锁)")
async def update_item(
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
# 工单也能改标题/优先级/截止/标签;状态必须走 /ticket/* 的动作接口
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
values: dict[str, Any] = {}
changes: dict[str, Any] = {}
@@ -662,7 +650,7 @@ async def delete_item(
async def add_comment(
item_id: uuid.UUID, body: CommentIn, ctx: MemberDep, session: SessionDep
) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
comment = Comment(
workspace_id=ctx.ws_id,
item_id=item.id,
@@ -686,7 +674,7 @@ async def add_comment(
@router.get("/{item_id}/comments", summary="评论列表")
async def list_comments(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
rows = (
(
await session.execute(