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(
+763
View File
@@ -0,0 +1,763 @@
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
一段流程长什么样:
任务卡住了
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
-> POST /items/{ticket_id}/ticket/start in_progress
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep, TenantCtx
from ..domain.ticket import (
CANCELABLE,
NOW_FIELD,
ensure_can_act,
returned_task_status,
target_status,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Item
from ..models_ticket import ItemLink, Notification, TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
MAX_REJECT_NOTE = 500
# -- 入参 ---------------------------------------------------------------
class PromoteIn(BaseModel):
assignee_id: uuid.UUID
title: str | None = Field(default=None, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
due_at: datetime | None = None
priority: int | None = Field(default=None, ge=0, le=3)
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
resolution_md: str = Field(
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
)
class ActionIn(BaseModel):
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
version: int | None = None
class LinkIn(BaseModel):
to_item_id: uuid.UUID
link_type: str = "related"
# -- 内部工具 -----------------------------------------------------------
async def _load_item(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 类型不符:对外一律"不存在"
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
if item.deleted_at is not None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
ext = await session.get(TicketExt, item_id)
if ext is None:
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
return ext
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
exists = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if exists is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _log(
session: AsyncSession,
item: Item,
actor_id: uuid.UUID | None,
action: str,
payload: dict | None = None,
) -> None:
session.add(
Activity(
workspace_id=item.workspace_id,
item_id=item.id,
actor_id=actor_id,
action=action,
payload=payload or {},
)
)
async def _notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_id: uuid.UUID,
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
return {
"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,
}
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
grouped = await tags_of(session, ws_id, [item.id])
return item_out(item, grouped.get(item.id))
async def _items_out(
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
) -> list[dict[str, Any]]:
grouped = await tags_of(session, ws_id, [i.id for i in items])
return [item_out(i, grouped.get(i.id)) for i in items]
async def _ticket_out(
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
) -> dict:
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
async def _patch_item(
session: AsyncSession,
item: Item,
values: dict[str, Any],
*,
expect_version: int | None,
) -> None:
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
if expect_version is not None:
where.append(Item.version == expect_version)
result = await session.execute(
update(Item)
.where(*where)
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条工单刚被改过,请刷新后重试",
details={"current_version": item.version},
)
await session.refresh(item)
# -- 升级 ---------------------------------------------------------------
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
if task.status in ("done", "canceled"):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"「{task.status}」的任务不能再升级成工单",
details={"status": task.status},
)
existing = (
await session.execute(
select(TicketExt.item_id).where(
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
)
)
).first()
if existing is not None:
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=(body.title or task.title)[:200],
body_md=body.body_md if body.body_md is not None else task.body_md,
status="open",
priority=body.priority if body.priority is not None else task.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at or task.due_at,
)
session.add(ticket)
await session.flush()
ext = TicketExt(
item_id=ticket.id,
workspace_id=ctx.ws_id,
requester_id=ctx.user.id,
assignee_id=body.assignee_id,
parent_item_id=task.id,
)
session.add(ext)
await session.flush()
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"你被指派了一条工单:{ticket.title}",
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.promote",
target_type="item",
target_id=str(ticket.id),
payload={"parent_item_id": str(task.id)},
)
await session.flush()
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
# -- 办理/验收 ----------------------------------------------------------
async def _advance(
ctx: TenantCtx,
session: AsyncSession,
item_id: uuid.UUID,
*,
action: str,
note: str = "",
approve: bool | None = None,
resolution_md: str = "",
version: int | None = None,
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
target = target_status(action, ticket.status)
ensure_can_act(
action=action,
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
now = datetime.now(timezone.utc)
previous = ticket.status
values: dict[str, Any] = {"status": target}
if target == "done":
values["completed_at"] = now
ext.verified_at = now
ext.verified_by = ctx.user.id
if resolution_md:
ext.resolution_md = resolution_md
if target == "in_progress" and action == "reject":
ext.reject_count = ext.reject_count + 1
stamp_field = NOW_FIELD.get(target)
if stamp_field and action != "reject":
setattr(ext, stamp_field, now)
await _patch_item(session, ticket, values, expect_version=version)
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
if note:
payload["note"] = note
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
# 通知对家:验收找提需求的人,其余找办理人
if action == "submit":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.requester_id,
kind="ticket_submitted",
item_id=ticket.id,
title=f"工单待你验收:{ticket.title}",
body=note or "办理人已提交,请确认结果",
)
elif action == "approve":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_approved",
item_id=ticket.id,
title=f"工单已通过验收:{ticket.title}",
body=note or "谢谢,这事结了",
)
elif action == "reject":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_rejected",
item_id=ticket.id,
title=f"工单被退回:{ticket.title}",
body=note or "请按反馈再改一版",
)
# 验收通过 -> 原任务闭环回流
returned = returned_task_status(target)
if returned and ext.parent_item_id:
parent = await session.get(Item, ext.parent_item_id)
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
parent.status = returned
parent.version += 1
parent.updated_at = now
await _log(
session,
parent,
ctx.user.id,
"ticket_done",
{"ticket_id": str(ticket.id), "status": returned},
)
await _notify(
session,
ws_id=ctx.ws_id,
user_id=parent.creator_id,
kind="task_unblocked",
item_id=parent.id,
title=f"任务可以继续了:{parent.title}",
body="它对应的工单已验收通过",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="accept")
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="start")
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="submit", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx,
session,
item_id,
action="approve" if body.approve else "reject",
note=body.note,
approve=body.approve,
resolution_md=body.resolution_md,
)
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="cancel", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
async def reassign(
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
ensure_can_act(
action="reassign",
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
if ticket.status not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{ticket.status}」的工单不能改派",
details={"allowed_from": list(CANCELABLE)},
)
if body.assignee_id == ext.assignee_id:
return await _ticket_out(session, ctx.ws_id, ticket, ext)
await _active_member_id(session, ctx.ws_id, body.assignee_id)
await _patch_item(
session,
ticket,
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
expect_version=None,
)
ext.assignee_id = body.assignee_id
ext.accepted_at = None
ext.submitted_at = None
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"转给你一条工单:{ticket.title}",
body="请先接收,再开始办理",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(
ctx: MemberDep,
session: SessionDep,
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt: Select = (
select(Item, TicketExt)
.join(TicketExt, TicketExt.item_id == Item.id)
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
)
if role == "assigned":
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
elif role == "requested":
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
elif role != "all":
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
if status:
stmt = stmt.where(Item.status.in_(status))
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Item.updated_at < parsed_cursor.created_at,
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
)
)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = (
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
)
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
return ok(
{
"tickets": [
{**out, "ticket": _ticket_ext_out(ext)}
for out, (_, ext) in zip(tickets, rows, strict=True)
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
# -- 事项关系(子任务 / 相关)------------------------------------------
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
children = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.from_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
parents = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.to_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.from_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
related = (
await session.execute(
select(ItemLink, Item)
.join(
Item,
or_(
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
),
)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.link_type == "related",
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
)
)
).all()
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
return [
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
for (link, _), out in zip(rows, packed, strict=True)
]
return ok(
{
"children": await _pack(children),
"parents": await _pack(parents),
"related": await _pack(related),
}
)
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
async def create_link(
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
) -> dict:
if body.link_type not in ("parent", "related"):
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
if body.to_item_id == item_id:
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
existing = (
await session.execute(
select(ItemLink).where(
ItemLink.from_item_id == body.to_item_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == body.link_type,
)
)
).scalar_one_or_none()
if existing is not None:
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
link = ItemLink(
workspace_id=ctx.ws_id,
from_item_id=body.to_item_id,
to_item_id=item_id,
link_type=body.link_type,
)
session.add(link)
await session.flush()
return ok({"id": str(link.id), "link_type": link.link_type})
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
async def delete_link(
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
) -> dict:
link = await session.get(ItemLink, link_id)
if (
link is None
or link.workspace_id != ctx.ws_id
or item_id
not in (
link.from_item_id,
link.to_item_id,
)
):
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
await session.delete(link)
return ok({"deleted": True})
# -- 通知 ---------------------------------------------------------------
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
return {
"id": str(n.id),
"kind": n.kind,
"item_id": str(n.item_id) if n.item_id else None,
"item_title": item_title,
"title": n.title,
"body": n.body,
"read_at": n.read_at,
"created_at": n.created_at,
}
@notify_router.get("", summary="我的通知(默认只看未读)")
async def list_notifications(
ctx: MemberDep,
session: SessionDep,
unread_only: bool = True,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt = select(Notification).where(
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
)
if unread_only:
stmt = stmt.where(Notification.read_at.is_(None))
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Notification.created_at < parsed_cursor.created_at,
and_(
Notification.created_at == parsed_cursor.created_at,
Notification.id < parsed_cursor.id,
),
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = list(rows[:limit])
titles: dict[uuid.UUID, str] = {}
item_ids = [n.item_id for n in rows if n.item_id]
if item_ids:
for item_id, title in (
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
).all():
titles[item_id] = title
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
return ok(
{
"notifications": [
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import func as sa_func
total = (
await session.execute(
select(sa_func.count(Notification.id)).where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
)
).scalar_one()
return ok({"unread": int(total)})
@notify_router.post("/{notification_id}/read", summary="标记已读")
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
n = await session.get(Notification, notification_id)
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
if n.read_at is None:
n.read_at = datetime.now(timezone.utc)
return ok({"read_at": n.read_at})
@notify_router.post("/read-all", summary="全部标记已读")
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import update as sa_update
result = await session.execute(
sa_update(Notification)
.where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
return ok({"marked": result.rowcount})
__all__ = ["notify_router", "router"]
+138
View File
@@ -0,0 +1,138 @@
"""工单状态机与权限判定(纯函数,不碰 ORM 以外的世界)。
状态机:
open ──accept──▶ accepted ──start──▶ in_progress ──submit──▶ pending_verify
▲ │
└────────reject────────────┘
│approve
▼
done
`canceled` 可以从任何未终结状态进入;`done` 与 `canceled` 是终态。
两条刻意的规则:
1. **驳回不是打回 open,而是回 in_progress**。办理人已经在做了,让他重新"认领"一次纯属折腾;
驳回次数单独计数(`reject_count`),用来提示"这事返工过"。
2. **谁也不能替别人验收**,除了管理员/所有者。提需求的人验收是这套流程的核心信任点,
如果办理人能自己点"通过",工单就退化成"打个勾"。
"""
from __future__ import annotations
import uuid
from ..errors import AppError, ErrorCode
TICKET_TRANSITIONS: dict[str, tuple[str, ...]] = {
"open": ("accepted", "canceled"),
"accepted": ("in_progress", "canceled"),
"in_progress": ("pending_verify", "canceled"),
"pending_verify": ("done", "in_progress", "canceled"),
"done": (),
"canceled": (),
}
# 动作 → (允许的前置状态, 目标状态);cancel 特殊处理
ACTION_RULES: dict[str, tuple[tuple[str, ...], str]] = {
"accept": (("open",), "accepted"),
"start": (("accepted",), "in_progress"),
"submit": (("in_progress",), "pending_verify"),
"approve": (("pending_verify",), "done"),
"reject": (("pending_verify",), "in_progress"),
}
CANCELABLE = ("open", "accepted", "in_progress", "pending_verify")
NOW_FIELD: dict[str, str] = {
"accepted": "accepted_at",
"pending_verify": "submitted_at",
"done": "verified_at",
}
def ensure_transition(current: str, target: str) -> None:
allowed = TICKET_TRANSITIONS.get(current, ())
if target not in allowed:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能直接变成「{target}」",
details={"from": current, "to": target, "allowed": list(allowed)},
)
def target_status(action: str, current: str) -> str:
"""动作 → 目标状态,顺带校验前置状态。"""
if action == "cancel":
if current not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{current}」的工单不能取消",
details={"from": current, "allowed": list(CANCELABLE)},
)
return "canceled"
if action not in ACTION_RULES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知动作:{action}")
allowed_from, target = ACTION_RULES[action]
if current not in allowed_from:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"工单当前是「{current}」,不能执行「{action}」",
# allowed 回答"现在还能去哪儿",allowed_from 回答"这个动作要什么前置状态":
# 客户端两种都要用得上,别让它自己拼。
details={
"from": current,
"action": action,
"allowed": list(TICKET_TRANSITIONS.get(current, ())),
"allowed_from": list(allowed_from),
},
)
return target
def ensure_can_act(
*,
action: str,
actor_id: uuid.UUID,
role: str,
assignee_id: uuid.UUID | None,
requester_id: uuid.UUID | None,
) -> None:
"""谁能做这个动作。
管理员/所有者可以代任何人操作(人休假、离职时的兜底),
普通成员只能动自己的位置上的事。
"""
is_admin = role in ("owner", "admin")
is_assignee = assignee_id is not None and actor_id == assignee_id
is_requester = requester_id is not None and actor_id == requester_id
if action in ("accept", "start", "submit"):
allowed = is_assignee or is_admin
hint = "只有被指派的人才能操作这条工单"
elif action in ("approve", "reject"):
allowed = is_requester or is_admin
hint = "只有提出这条工单的人(或管理员)才能验收"
elif action == "cancel":
allowed = is_requester or is_assignee or is_admin
hint = "只有提出人、办理人或管理员能取消"
else:
allowed = is_admin or is_requester
hint = "权限不足"
if not allowed:
raise AppError(ErrorCode.TICKET_FORBIDDEN, hint, details={"action": action})
def returned_task_status(ticket_status: str) -> str | None:
"""工单状态变化后,原任务该回到什么状态;None 表示不动。
闭环回流只发生在验收通过那一刻——这是"事情真的办完了"的唯一信号。
取消不回填:取消意味着不办了,原任务继续躺在 blocked 里等人处理才对。
"""
if ticket_status == "done":
return "todo"
return None
+16
View File
@@ -40,6 +40,15 @@ class ErrorCode(IntEnum):
ITEM_NOT_FOUND = 5001
ITEM_STATUS_INVALID = 5002
TAG_NOT_FOUND = 5003
LINK_NOT_FOUND = 5004
LINK_CONFLICT = 5005
# 6xxx 工单域
TICKET_NOT_FOUND = 6001
TICKET_ALREADY_EXISTS = 6002
TICKET_STATUS_INVALID = 6003
TICKET_FORBIDDEN = 6004
NOTIFICATION_NOT_FOUND = 6005
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
@@ -67,6 +76,13 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.ITEM_NOT_FOUND: 404,
ErrorCode.ITEM_STATUS_INVALID: 409,
ErrorCode.TAG_NOT_FOUND: 404,
ErrorCode.LINK_NOT_FOUND: 404,
ErrorCode.LINK_CONFLICT: 409,
ErrorCode.TICKET_NOT_FOUND: 404,
ErrorCode.TICKET_ALREADY_EXISTS: 409,
ErrorCode.TICKET_STATUS_INVALID: 409,
ErrorCode.TICKET_FORBIDDEN: 403,
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
}
+3 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException
from .api import auth, items, workspaces
from .api import auth, items, tickets, workspaces
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -102,6 +102,8 @@ app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(workspaces.router, prefix=settings.api_prefix)
app.include_router(items.router, prefix=settings.api_prefix)
app.include_router(items.tag_router, prefix=settings.api_prefix)
app.include_router(tickets.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+4 -2
View File
@@ -133,9 +133,10 @@ class AuditLog(Base):
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
#
# 任务域的表在 models_task.py —— 它 import 本模块的 Base,所以只能在文件末尾反向 import,
# 否则循环导入。
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
# 所以只能在文件末尾反向 import,否则循环导入。
from .models_task import TASK_TENANT_TABLES # noqa: E402
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
TENANT_TABLES: tuple[str, ...] = (
"workspace",
@@ -143,4 +144,5 @@ TENANT_TABLES: tuple[str, ...] = (
"invitation",
"audit_log",
*TASK_TENANT_TABLES,
*TICKET_TENANT_TABLES,
)
+145
View File
@@ -0,0 +1,145 @@
"""工单域模型(M2):ticket_ext / item_link / notification。
三条设计约束(对应 PRODUCT-PLAN 的 D1/D2):
1. **工单不是新表,是 `item` 的一个 kind**。标题、正文、截止、标签、评论、事件流
全部复用,M1 写的每一处代码对工单依然成立;`ticket_ext` 只补"谁请谁、验没验收"。
2. **升级不是搬家**。原任务留在原地并进入 `blocked`(它确实被卡住了),工单是**新建**的一条
item,通过 `TicketExt.parent_item_id` 指回去。工单验收通过时把原任务从 blocked 拉回 todo,
这就是"闭环回流"——任务那条时间线完整保留,历史不被搬来搬去。
3. **通知只记录"发生了什么",不做已读以外的状态机**。通知看错、漏看是常态,
真正的状态永远以 item 为准,通知只是提醒,不能变成第二份真相。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps, new_uuid
# 工单状态机(与任务共用 item.status 这一列,但取值集合不同)
TICKET_STATUSES: tuple[str, ...] = (
"open",
"accepted",
"in_progress",
"pending_verify",
"done",
"canceled",
)
LINK_TYPES: tuple[str, ...] = ("parent", "related")
class TicketExt(Base, Timestamps):
"""工单的附加信息,与 item 一比一。"""
__tablename__ = "ticket_ext"
__table_args__ = (
Index("ix_ticket_ws_assignee", "workspace_id", "assignee_id"),
Index("ix_ticket_ws_requester", "workspace_id", "requester_id"),
CheckConstraint("reject_count >= 0", name="ck_ticket_reject_count"),
)
# item_id 既是主键又是外键:一条 item 最多一条 ticket_ext,天然 1:1
item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
# 谁提的这件事(下单的人)
requester_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 谁在办(冗余一份,免得每次查工单都要回 item 表;两处必须一致,改指派时同时写)
assignee_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
# 从哪条任务升级来的;独立创建的工单为空
parent_item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
verified_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
# 被驳回次数:不是为了考核,是为了让提需求的人知道"这事返工过几次"
reject_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 办完的结论:写什么由办理人决定,M3 从这里长知识草稿
resolution_md: Mapped[str] = mapped_column(Text, nullable=False, default="")
class ItemLink(Base, Timestamps):
"""事项之间的关系。M2 只做两种:父子(子任务)与相关。
`from_item_id → to_item_id` 是有方向的:parent 表示 from 是 to 的子项。
"""
__tablename__ = "item_link"
__table_args__ = (
UniqueConstraint("from_item_id", "to_item_id", "link_type", name="uq_item_link"),
CheckConstraint("from_item_id <> to_item_id", name="ck_item_link_not_self"),
CheckConstraint("link_type IN ('parent','related')", name="ck_item_link_type"),
Index("ix_item_link_ws_from", "workspace_id", "from_item_id"),
Index("ix_item_link_ws_to", "workspace_id", "to_item_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
)
from_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
to_item_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
)
link_type: Mapped[str] = mapped_column(String(16), nullable=False, default="related")
class Notification(Base, Timestamps):
"""站内通知。收件人维度,不是工作区里的公共消息流。"""
__tablename__ = "notification"
__table_args__ = (
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
Index("ix_notification_ws_created", "workspace_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
)
user_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
item_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE")
)
title: Mapped[str] = mapped_column(String(120), nullable=False)
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
TICKET_TENANT_TABLES: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
+61
View File
@@ -0,0 +1,61 @@
"""统一的 item / tag 序列化。
为什么要单独一份:工单接口最初自己拼了一份 dict,忘了 `tags` 字段,
前端列表把 `item.tags` 当数组用,一升级成工单就整页白屏。
任何返回 item 的接口都必须用这里的 `item_out`,并用 `tags_of` 把标签一起取回来。
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models_task import Item, ItemTag, Tag
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,
}
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