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:
+34
-46
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user