feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s

M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。

通知(backend/app/services/notify.py 统一出口)
- 七类触发:task_assigned、task_status_changed、item_commented、
  ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked
- 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断
- 已读:GET /notifications、/unread-count、POST /read、/read-all

手工开单
- POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item,
  ticket_ext.parent_item_id 为空,验收通过不回流任务
- 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上")

前端
- Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标);
  工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新;
  任务与工单支持 ?open= 深链落地即展开
- 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框

验证
- pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例)
- ruff check / format --check 全 0
- tsc --noEmit 通过;vite build 成功
- web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零)
- 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证,
  装回 JDK 17 后第一件事是 assembleDebug
This commit is contained in:
Steven
2026-10-30 02:19:42 +08:00
parent d079564d6c
commit 91b3dde670
23 changed files with 1674 additions and 60 deletions
+83 -9
View File
@@ -39,6 +39,7 @@ 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
from ..services.notify import notify
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class TicketCreateIn(BaseModel):
"""手工开单:不经过任务升级,直接指定谁去办。"""
title: str = Field(min_length=1, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
assignee_id: uuid.UUID
due_at: datetime | None = None
priority: int = Field(default=0, ge=0, le=3)
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
@@ -139,16 +150,18 @@ async def _notify(
item_id: uuid.UUID | None,
title: str,
body: str = "",
actor_id: uuid.UUID | None = None,
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
await notify(
session,
ws_id=ws_id,
user_ids=(user_id,),
kind=kind,
item_id=item_id,
title=title,
body=body,
actor_id=actor_id,
)
@@ -520,6 +533,67 @@ async def reassign(
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 手工开单 -----------------------------------------------------------
@router.post("/tickets", summary="手工开一张工单(不依赖任务升级)")
async def create_ticket(body: TicketCreateIn, ctx: MemberDep, session: SessionDep) -> dict:
"""开会、口头上被派活、临时找同事帮忙——这些事从来没有过"任务"。
工单不是任务的衍生物,只是"要别人办的事"的一种形态:它可以凭空存在,
也可以由任务升级而来(那条路径见 promote)。两条路都落在 kind=ticket 的
同一条 item 上,所以看板、评论、验收、沉淀知识全都共用。
"""
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=body.title.strip(),
body_md=body.body_md,
status="open",
priority=body.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.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=None,
)
session.add(ext)
await session.flush()
await _log(session, ticket, ctx.user.id, "ticket_created", {"manual": True})
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 '同事'} 直接给你开了张工单,办完提交验收",
actor_id=ctx.user.id,
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.create",
target_type="item",
target_id=str(ticket.id),
payload={"manual": True},
)
await session.flush()
ticket_out = await _item_out(session, ctx.ws_id, ticket)
return ok({**ticket_out, "ticket": _ticket_ext_out(ext)}, message="已创建")
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(